Redo provider managers lifecycle (#732)

Update library with more efficient choices for interacting with the
providers API.

Fixes #731
This commit is contained in:
Davide 2024-10-13 11:36:34 +02:00 committed by GitHub
parent a5d4f6aee5
commit 87c7d63678
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 189 additions and 257 deletions

View File

@ -41,8 +41,7 @@
"kind" : "remoteSourceControl",
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
"state" : {
"revision" : "ebb142e836e9e2a8a1867c7ae3d4f44b6b96e917",
"version" : "0.9.1"
"revision" : "aeb982951e2798863e28f55081dd25e2221083e3"
}
},
{

View File

@ -27,8 +27,8 @@ let package = Package(
)
],
dependencies: [
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.9.1"),
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "0bfd4578b71a905584cdd5c9c39ab3087521af78"),
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.9.0"),
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "aeb982951e2798863e28f55081dd25e2221083e3"),
// .package(path: "../../../passepartoutkit-source"),
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", from: "0.9.1"),
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"),

View File

@ -1,39 +0,0 @@
//
// ProviderFactory.swift
// Passepartout
//
// Created by Davide De Rosa on 10/8/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import Foundation
import PassepartoutKit
@MainActor
public final class ProviderFactory: ObservableObject {
public let providerManager: ProviderManager
public let vpnProviderManager: VPNProviderManager
public init(providerManager: ProviderManager, vpnProviderManager: VPNProviderManager) {
self.providerManager = providerManager
self.vpnProviderManager = vpnProviderManager
}
}

View File

@ -46,7 +46,7 @@ public final class AppContext: ObservableObject {
public let registry: Registry
public let providerFactory: ProviderFactory
public let providerManager: ProviderManager
private let constants: Constants
@ -59,7 +59,7 @@ public final class AppContext: ObservableObject {
tunnel: Tunnel,
tunnelEnvironment: TunnelEnvironment,
registry: Registry,
providerFactory: ProviderFactory,
providerManager: ProviderManager,
constants: Constants
) {
self.iapManager = iapManager
@ -73,7 +73,7 @@ public final class AppContext: ObservableObject {
interval: constants.tunnel.refreshInterval
)
self.registry = registry
self.providerFactory = providerFactory
self.providerManager = providerManager
self.constants = constants
subscriptions = []

View File

@ -145,13 +145,13 @@ extension OnDemandModule.Policy: LocalizableEntity {
}
extension VPNServer {
public var sortableRegion: String {
[countryCodes.first?.localizedAsRegionCode, area]
public var region: String {
[provider.countryCodes.first?.localizedAsRegionCode, provider.area]
.compactMap { $0 }
.joined(separator: " - ")
}
public var sortableAddresses: String {
public var address: String {
if let hostname {
return hostname
}

View File

@ -62,9 +62,9 @@ extension AppContext {
tunnel: Tunnel(strategy: FakeTunnelStrategy(environment: env)),
tunnelEnvironment: env,
registry: registry,
providerFactory: ProviderFactory(
providerManager: ProviderManager(repository: InMemoryProviderRepository()),
vpnProviderManager: VPNProviderManager(repository: InMemoryVPNProviderRepository())
providerManager: ProviderManager(
repository: InMemoryProviderRepository(),
vpnRepository: InMemoryVPNProviderRepository()
),
constants: .shared
)
@ -83,12 +83,6 @@ extension ProfileManager {
}
}
extension ProviderFactory {
public static var mock: ProviderFactory {
AppContext.mock.providerFactory
}
}
extension ProfileProcessor {
public static var mock: ProfileProcessor {
AppContext.mock.profileProcessor
@ -107,6 +101,12 @@ extension ConnectionObserver {
}
}
extension ProviderManager {
public static var mock: ProviderManager {
AppContext.mock.providerManager
}
}
// MARK: - Profile
extension Profile {

View File

@ -60,6 +60,9 @@ struct OpenVPNView: View {
@Binding
private var providerEntity: VPNEntity<OpenVPN.Configuration>?
@StateObject
private var vpnProviderManager = VPNProviderManager()
init(serverConfiguration: OpenVPN.Configuration) {
let module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder())
let editor = ProfileEditor(modules: [module])
@ -99,41 +102,35 @@ private extension OpenVPNView {
var providerModifier: some ViewModifier {
ProviderPanelModifier(
isRequired: draft.configurationBuilder == nil,
entityType: VPNEntity<OpenVPN.Configuration>.self,
providerId: $providerId,
selectedEntity: $providerEntity,
providerContent: providerContentView
providerContent: providerContentView,
onSelectProvider: onSelectProvider
)
}
@ViewBuilder
var contentView: some View {
credentialsView
if providerId == nil {
manualView
}
func providerContentView(providerId: ProviderID) -> some View {
providerServerRow
moduleGroup(for: accountRows)
}
@ViewBuilder
func providerContentView(providerId: ProviderID, entity: VPNEntity<OpenVPN.Configuration>?) -> some View {
NavigationLink(value: Subroute.providerServer(id: providerId)) {
var providerServerRow: some View {
NavigationLink(value: Subroute.providerServer) {
HStack {
Text("Server")
if let entity {
Text(Strings.Global.server)
if let providerEntity {
Spacer()
Text(entity.server.hostname ?? entity.server.serverId)
Text(providerEntity.server.hostname ?? providerEntity.server.serverId)
.foregroundStyle(.secondary)
}
}
}
credentialsView
}
var credentialsView: some View {
moduleSection(for: accountRows, header: Strings.Global.account)
}
@ViewBuilder
var manualView: some View {
var contentView: some View {
moduleSection(for: accountRows, header: Strings.Global.account)
moduleSection(for: remotesRows, header: Strings.Modules.Openvpn.remotes)
if !isServerPushed {
moduleSection(for: pullRows, header: Strings.Modules.Openvpn.pull)
@ -159,6 +156,20 @@ private extension OpenVPNView {
}
private extension OpenVPNView {
func onSelectProvider(manager: ProviderManager) {
guard let providerId else {
return
}
vpnProviderManager.view = manager.vpnView(
withId: providerId,
initialParameters: .init(sorting: [
.localizedCountry,
.area,
.hostname
])
)
}
func onSelect(server: VPNServer, preset: VPNPreset<OpenVPN.Configuration>) {
providerEntity = VPNEntity(server: server, preset: preset)
}
@ -172,7 +183,7 @@ private extension OpenVPNView {
private extension OpenVPNView {
enum Subroute: Hashable {
case providerServer(id: ProviderID)
case providerServer
case credentials
}
@ -180,9 +191,9 @@ private extension OpenVPNView {
@ViewBuilder
func destination(for route: Subroute) -> some View {
switch route {
case .providerServer(let id):
case .providerServer:
VPNProviderServerView<OpenVPN.Configuration>(
providerId: id,
manager: vpnProviderManager,
onSelect: onSelect
)

View File

@ -71,12 +71,18 @@ struct HashableRoute: Hashable {
}
extension View {
func moduleSection(for rows: [ModuleRow]?, header: String) -> some View {
func moduleGroup(for rows: [ModuleRow]?) -> some View {
rows.map { rows in
Group {
ForEach(rows, id: \.self, content: moduleRowView)
}
.themeSection(header: header)
}
}
func moduleSection(for rows: [ModuleRow]?, header: String) -> some View {
rows.map { rows in
moduleGroup(for: rows)
.themeSection(header: header)
}
}
}

View File

@ -26,6 +26,9 @@
import AppLibrary
import PassepartoutKit
import SwiftUI
import UtilsLibrary
// FIXME: #703, providers UI, reorg subviews
struct ProviderPanelModifier<Entity, ProviderContent>: ViewModifier where Entity: ProviderEntity, Entity.Configuration: ProviderConfigurationIdentifiable & Codable, ProviderContent: View {
@ -36,25 +39,31 @@ struct ProviderPanelModifier<Entity, ProviderContent>: ViewModifier where Entity
let isRequired: Bool
let entityType: Entity.Type
@Binding
var providerId: ProviderID?
@Binding
var selectedEntity: Entity?
@ViewBuilder
let providerContent: (ProviderID, Entity?) -> ProviderContent
let providerContent: (ProviderID) -> ProviderContent
let onSelectProvider: (ProviderManager) -> Void
func body(content: Content) -> some View {
providerPicker
.task {
await refreshIndex()
}
debugChanges()
return Group {
providerPicker
.onLoad(perform: loadCurrentProvider)
if let providerId {
providerContent(providerId, selectedEntity)
} else if !isRequired {
content
if let providerId {
providerContent(providerId)
.asSectionWithTrailingContent {
refreshButton
}
.disabled(providerManager.isLoading)
} else if !isRequired {
content
}
}
}
}
@ -70,33 +79,91 @@ private extension ProviderPanelModifier {
let hasProviders = !supportedProviders.isEmpty
return Picker(Strings.Global.provider, selection: $providerId) {
if hasProviders {
Text(Strings.Global.none)
// FIXME: #703, providers UI
Text("Select a provider")
.tag(nil as ProviderID?)
ForEach(supportedProviders, id: \.id) {
Text($0.description)
.tag($0.id as ProviderID?)
}
} else {
Text(" ") // enforce constant picker height on iOS
// enforce constant picker height on iOS
Text(providerManager.isLoading ? "..." : "Unavailable")
.tag(providerId) // tag always exists
}
}
.onChange(of: providerId) { _ in
selectedEntity = nil
.onChange(of: providerId) { newId in
Task {
if let newId {
await refreshInfrastructure(for: newId)
}
onSelectProvider(providerManager)
}
}
.disabled(!hasProviders)
}
var refreshButton: some View {
Button {
guard let providerId else {
return
}
Task {
await refreshInfrastructure(for: providerId)
}
} label: {
HStack {
Text(Strings.Views.Provider.Vpn.refreshInfrastructure)
#if os(iOS)
if let providerId, providerManager.pendingServices.contains(.provider(providerId)) {
Spacer()
ProgressView()
}
#endif
}
}
.disabled(providerManager.isLoading || providerId == nil)
}
}
private extension ProviderPanelModifier {
func loadCurrentProvider() {
Task {
if let providerId {
async let index = await refreshIndex()
async let provider = await refreshInfrastructure(for: providerId)
_ = await (index, provider)
onSelectProvider(providerManager)
} else {
await refreshIndex()
}
}
}
// FIXME: #707, fetch bundled providers on launch
// FIXME: #704, rate-limit fetch
func refreshIndex() async {
@discardableResult
func refreshIndex() async -> Bool {
do {
try await providerManager.fetchIndex(from: apis)
return true
} catch {
pp_log(.app, .error, "Unable to fetch index: \(error)")
return false
}
}
@discardableResult
func refreshInfrastructure(for providerId: ProviderID) async -> Bool {
do {
try await providerManager.fetchVPNInfrastructure(
from: apis,
for: providerId,
ofType: Entity.Configuration.self
)
return true
} catch {
// FIXME: #703, alert unable to refresh infrastructure
pp_log(.app, .error, "Unable to refresh infrastructure: \(error)")
return false
}
}
}
@ -110,26 +177,17 @@ private extension ProviderID {
// MARK: - Preview
#Preview {
@State
var providerId: ProviderID? = .hideme
@State
var vpnEntity: VPNEntity<OpenVPN.Configuration>?
return List {
List {
EmptyView()
.modifier(ProviderPanelModifier(
apis: [API.bundled],
isRequired: false,
providerId: $providerId,
selectedEntity: $vpnEntity,
providerContent: { _, entity in
HStack {
Text("Server")
Spacer()
Text(entity?.server.serverId ?? "None")
}
}
entityType: VPNEntity<OpenVPN.Configuration>.self,
providerId: .constant(.hideme),
providerContent: { _ in
Text("Server")
},
onSelectProvider: { _ in }
))
}
.withMockEnvironment()

View File

@ -31,16 +31,12 @@ struct VPNFiltersModifier<Configuration>: ViewModifier where Configuration: Deco
@ObservedObject
var manager: VPNProviderManager
let providerId: ProviderID
let onRefresh: () async -> Void
@State
var isFiltersPresented = false
func body(content: Content) -> some View {
contentView(with: content)
.onChange(of: manager.filters) { _ in
.onChange(of: manager.parameters.filters) { _ in
Task {
await manager.applyFilters()
}

View File

@ -34,10 +34,6 @@ struct VPNFiltersView<Configuration>: View where Configuration: Decodable {
@ObservedObject
var manager: VPNProviderManager
let providerId: ProviderID
let onRefresh: () async -> Void
@State
private var isRefreshing = false
@ -54,22 +50,16 @@ struct VPNFiltersView<Configuration>: View where Configuration: Decodable {
HStack {
Spacer()
clearFiltersButton
refreshButton
}
#endif
}
#if os(iOS)
Section {
refreshButton
}
#endif
}
}
}
private extension VPNFiltersView {
var categoryPicker: some View {
Picker("Category", selection: $manager.filters.categoryName) {
Picker("Category", selection: $manager.parameters.filters.categoryName) {
Text("Any")
.tag(nil as String?)
ForEach(categories, id: \.self) {
@ -80,7 +70,7 @@ private extension VPNFiltersView {
}
var countryPicker: some View {
Picker("Country", selection: $manager.filters.countryCode) {
Picker("Country", selection: $manager.parameters.filters.countryCode) {
Text("Any")
.tag(nil as String?)
ForEach(countries, id: \.code) {
@ -92,8 +82,8 @@ private extension VPNFiltersView {
@ViewBuilder
var presetPicker: some View {
if manager.anyPresets.count > 1 {
Picker("Preset", selection: $manager.filters.presetId) {
if manager.allPresets.count > 1 {
Picker("Preset", selection: $manager.parameters.filters.presetId) {
Text("Any")
.tag(nil as String?)
ForEach(presets, id: \.presetId) {
@ -111,27 +101,6 @@ private extension VPNFiltersView {
}
}
}
var refreshButton: some View {
Button {
Task {
isRefreshing = true
await onRefresh()
isRefreshing = false
}
} label: {
HStack {
Text(Strings.Views.Provider.Vpn.refreshInfrastructure)
#if os(iOS)
if isRefreshing {
Spacer()
ProgressView()
}
#endif
}
}
.disabled(isRefreshing)
}
}
private extension VPNFiltersView {
@ -149,7 +118,7 @@ private extension VPNFiltersView {
let allCodes = manager
.allServers
.values
.flatMap(\.countryCodes)
.flatMap(\.provider.countryCodes)
return Set(allCodes)
.map {
@ -171,10 +140,6 @@ private extension VPNFiltersView {
#Preview {
NavigationStack {
VPNFiltersView<String>(
manager: ProviderFactory.mock.vpnProviderManager,
providerId: .hideme,
onRefresh: {}
)
VPNFiltersView<String>(manager: VPNProviderManager())
}
}

View File

@ -29,66 +29,24 @@ import SwiftUI
struct VPNProviderServerView<Configuration>: View where Configuration: ProviderConfigurationIdentifiable & Hashable & Codable {
@EnvironmentObject
private var providerManager: ProviderManager
@EnvironmentObject
private var vpnProviderManager: VPNProviderManager
@Environment(\.dismiss)
private var dismiss
var apis: [APIMapper] = API.shared
let providerId: ProviderID
@ObservedObject
var manager: VPNProviderManager
let onSelect: (_ server: VPNServer, _ preset: VPNPreset<Configuration>) -> Void
@State
private var isLoading = true
@State
var sortOrder = [
KeyPathComparator(\VPNServer.sortableRegion)
]
@State
var sortedServers: [VPNServer] = []
// FIXME: #703, flickers on appear
var body: some View {
serversView
.modifier(VPNFiltersModifier<Configuration>(
manager: vpnProviderManager,
providerId: providerId,
onRefresh: {
await refreshInfrastructure(for: providerId)
}
))
.themeAnimation(on: isLoading, category: .providers)
.navigationTitle(providerMetadata?.description ?? Strings.Global.servers)
.task {
await loadInfrastructure(for: providerId)
}
.onReceive(vpnProviderManager.$filteredServers, perform: onFilteredServers)
}
}
private extension VPNProviderServerView {
var providerMetadata: ProviderMetadata? {
providerManager.metadata(withId: providerId)
.modifier(VPNFiltersModifier<Configuration>(manager: manager))
.navigationTitle(Strings.Global.servers)
}
}
// MARK: - Actions
extension VPNProviderServerView {
func onFilteredServers(_ servers: [String: VPNServer]) {
sortedServers = servers
.values
.sorted(using: sortOrder)
}
func selectServer(_ server: VPNServer) {
guard let preset = compatiblePreset(with: server) else {
// FIXME: #703, alert select a preset
@ -101,7 +59,7 @@ extension VPNProviderServerView {
private extension VPNProviderServerView {
func compatiblePreset(with server: VPNServer) -> VPNPreset<Configuration>? {
vpnProviderManager
manager
.presets(ofType: Configuration.self)
.first {
if let supportedIds = server.provider.supportedPresetIds {
@ -110,38 +68,13 @@ private extension VPNProviderServerView {
return true
}
}
func loadInfrastructure(for providerId: ProviderID) async {
await vpnProviderManager.setProvider(providerId)
if await vpnProviderManager.lastUpdated() == nil {
await refreshInfrastructure(for: providerId)
}
isLoading = false
}
// FIXME: #704, rate-limit fetch
func refreshInfrastructure(for providerId: ProviderID) async {
do {
isLoading = true
try await vpnProviderManager.fetchInfrastructure(
from: apis,
for: providerId,
ofType: Configuration.self
)
isLoading = false
} catch {
// FIXME: #703, alert unable to refresh infrastructure
pp_log(.app, .error, "Unable to refresh infrastructure: \(error)")
isLoading = false
}
}
}
// MARK: - Preview
#Preview {
NavigationStack {
VPNProviderServerView<OpenVPN.Configuration>(apis: [API.bundled], providerId: .protonvpn) { _, _ in
VPNProviderServerView<OpenVPN.Configuration>(manager: VPNProviderManager()) { _, _ in
}
}
.withMockEnvironment()

View File

@ -43,7 +43,7 @@ extension VPNFiltersModifier {
}
.themeModal(isPresented: $isFiltersPresented) {
NavigationStack {
VPNFiltersView<Configuration>(manager: manager, providerId: providerId, onRefresh: onRefresh)
VPNFiltersView<Configuration>(manager: manager)
.navigationTitle("Filters")
.navigationBarTitleDisplayMode(.inline)
}

View File

@ -30,12 +30,12 @@ import SwiftUI
// FIXME: #703, providers UI
extension VPNProviderServerView {
@ViewBuilder
var serversView: some View {
sortedServers.nilIfEmpty.map { servers in
ForEach(sortedServers) { server in
Button("\(server.hostname ?? server.id) \(server.countryCodes)") {
selectServer(server)
}
ForEach(manager.filteredServers) { server in
Button("\(server.hostname ?? server.id) \(server.provider.countryCodes)") {
selectServer(server)
}
}
}

View File

@ -30,7 +30,7 @@ import SwiftUI
extension VPNFiltersModifier {
func contentView(with content: Content) -> some View {
VStack {
VPNFiltersView<Configuration>(manager: manager, providerId: providerId, onRefresh: onRefresh)
VPNFiltersView<Configuration>(manager: manager)
.padding()
content
}

View File

@ -30,14 +30,18 @@ import SwiftUI
// FIXME: #703, providers UI
extension VPNProviderServerView {
@ViewBuilder
var serversView: some View {
Table(sortedServers, sortOrder: $sortOrder) {
TableColumn("Region", value: \.sortableRegion)
.width(max: 200.0)
Table(manager.filteredServers) {
TableColumn("Region") { server in
Text(server.region)
}
.width(max: 200.0)
TableColumn("Address", value: \.sortableAddresses)
TableColumn("Address", value: \.address)
TableColumn("", value: \.serverId) { server in
TableColumn("") { server in
Button {
selectServer(server)
} label: {

View File

@ -31,10 +31,9 @@ extension View {
public func withEnvironment(from context: AppContext, theme: Theme) -> some View {
environmentObject(theme)
.environmentObject(context.iapManager)
.environmentObject(context.connectionObserver)
.environmentObject(context.providerFactory.providerManager)
.environmentObject(context.providerFactory.vpnProviderManager)
.environmentObject(context.profileProcessor)
.environmentObject(context.connectionObserver)
.environmentObject(context.providerManager)
}
public func withMockEnvironment() -> some View {

View File

@ -63,7 +63,7 @@ extension API {
#if DEBUG
[API.bundled]
#else
[API.remoteThenBundled]
API.remoteThenBundled
#endif
}

View File

@ -40,7 +40,7 @@ extension AppContext {
tunnel: .shared,
tunnelEnvironment: .shared,
registry: .shared,
providerFactory: .shared,
providerManager: .shared,
constants: .shared
)
}
@ -208,10 +208,10 @@ private extension ProfileManager {
// MARK: -
// FIXME: #705, store providers to Core Data
extension ProviderFactory {
static let shared = ProviderFactory(
providerManager: ProviderManager(repository: InMemoryProviderRepository()),
vpnProviderManager: VPNProviderManager(repository: InMemoryVPNProviderRepository())
extension ProviderManager {
static let shared = ProviderManager(
repository: InMemoryProviderRepository(),
vpnRepository: InMemoryVPNProviderRepository()
)
}