From da87ca698afbedbe9be4e1df57443eca3fd7d15c Mon Sep 17 00:00:00 2001 From: Davide Date: Fri, 11 Oct 2024 00:24:06 +0200 Subject: [PATCH] Add initial support for providers (#723) Initial integration of providers via API: - Generic views and modifiers for provider/server selection - Add in OpenVPNView - Prepare in WireGuardView Also: - Introduce ProfileProcessor, move IAP processing there - Move .asModuleView() to ModuleViewModifier for proper animation - Use .themeModal() rather than .sheet() --- Passepartout.xcodeproj/project.pbxproj | 2 +- Passepartout/Library/Package.swift | 1 + .../Business/ProviderFactory.swift} | 27 +-- .../Sources/AppUI/Business/AppContext.swift | 12 +- .../AppUI/Business/ProfileEditor.swift | 26 +-- .../AppUI/Business/ProfileProcessor.swift | 9 +- .../AppUI/Business/Tunnel+Extensions.swift | 4 +- .../AppUI/Domain/EditableProfile.swift | 10 +- .../Library/Sources/AppUI/Domain/Issue.swift | 2 +- .../L10n/EditableModule+Description.swift | 2 +- .../AppUI/L10n/PassepartoutKit+L10n.swift | 25 +++ .../Sources/AppUI/L10n/SwiftGen+Strings.swift | 8 + .../Library/Sources/AppUI/Mock/Mock.swift | 21 +- .../Resources/en.lproj/Localizable.strings | 3 + .../Sources/AppUI/Views/Modules/DNSView.swift | 8 +- .../AppUI/Views/Modules/HTTPProxyView.swift | 8 +- .../AppUI/Views/Modules/IPView+Route.swift | 2 +- .../Sources/AppUI/Views/Modules/IPView.swift | 8 +- .../AppUI/Views/Modules/OnDemandView.swift | 10 +- .../AppUI/Views/Modules/OpenVPNView.swift | 184 +++++++++++++----- .../AppUI/Views/Modules/WireGuardView.swift | 61 ++++-- .../Profile/iOS/ProfileEditView+iOS.swift | 2 +- .../Profile/macOS/ModuleListView+macOS.swift | 2 +- .../macOS/ProfileGeneralView+macOS.swift | 2 +- .../DefaultModuleViewFactory.swift | 19 -- .../Views/ProfileEditor/ModuleSection.swift | 6 - .../ProfileEditor/ModuleViewModifier.swift | 58 ++++++ .../ProfileEditor/ProfileEditor+UI.swift | 20 +- .../Provider/ProviderPanelModifier.swift | 135 +++++++++++++ .../Views/Provider/VPNFiltersModifier.swift | 49 +++++ .../AppUI/Views/Provider/VPNFiltersView.swift | 180 +++++++++++++++++ .../Provider/VPNProviderServerView.swift | 148 ++++++++++++++ .../Provider/iOS/VPNFiltersModifier+iOS.swift | 55 ++++++ .../iOS/VPNProviderServerView+iOS.swift | 44 +++++ .../macOS/VPNFiltersModifier+macOS.swift | 40 ++++ .../macOS/VPNProviderServerView+macOS.swift | 52 +++++ .../Sources/AppUI/Views/Theme/Theme+UI.swift | 8 +- .../Sources/AppUI/Views/Theme/Theme+iOS.swift | 2 +- .../AppUI/Views/Theme/Theme+macOS.swift | 2 +- .../AppUI/Views/UI/ConnectionStatusView.swift | 4 +- .../AppUI/Views/UI/StorageSection.swift | 2 +- .../AppUI/Views/UI/TunnelRestartButton.swift | 4 +- .../AppUI/Views/UI/TunnelToggleButton.swift | 7 +- .../AppUI/Views/UI/View+Environment.swift | 4 + .../CommonLibrary/Domain/Constants.swift | 4 + .../Sources/CommonLibrary/Shared.swift | 36 ++++ ...ions.swift => Collection+Extensions.swift} | 8 +- .../Extensions/String+Extensions.swift | 48 +++++ .../Views/GenericCreditsView.swift | 10 +- .../Tests/AppUITests/ProfileEditorTests.swift | 6 +- Passepartout/Shared/Shared+App.swift | 41 ++++ 51 files changed, 1237 insertions(+), 194 deletions(-) rename Passepartout/Library/Sources/{AppUI/IAP/IAPManager+ProfileProcessor.swift => AppLibrary/Business/ProviderFactory.swift} (50%) create mode 100644 Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ModuleViewModifier.swift create mode 100644 Passepartout/Library/Sources/AppUI/Views/Provider/ProviderPanelModifier.swift create mode 100644 Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersModifier.swift create mode 100644 Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersView.swift create mode 100644 Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderServerView.swift create mode 100644 Passepartout/Library/Sources/AppUI/Views/Provider/iOS/VPNFiltersModifier+iOS.swift create mode 100644 Passepartout/Library/Sources/AppUI/Views/Provider/iOS/VPNProviderServerView+iOS.swift create mode 100644 Passepartout/Library/Sources/AppUI/Views/Provider/macOS/VPNFiltersModifier+macOS.swift create mode 100644 Passepartout/Library/Sources/AppUI/Views/Provider/macOS/VPNProviderServerView+macOS.swift rename Passepartout/Library/Sources/UtilsLibrary/Extensions/{Array+Extensions.swift => Collection+Extensions.swift} (87%) create mode 100644 Passepartout/Library/Sources/UtilsLibrary/Extensions/String+Extensions.swift diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index 859177d1..c82697cf 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -168,9 +168,9 @@ 0E7E3D5B2B9345FD002BBDB4 /* App.entitlements */, 0E7C3CCC2C9AF44600B72E69 /* AppDelegate.swift */, 0EB08B962CA46F4900A02591 /* AppPlist.strings */, + 0E7E3D5C2B9345FD002BBDB4 /* Assets.xcassets */, 0EC066D02C7DC47600D88A94 /* LaunchScreen.storyboard */, 0E7E3D5F2B9345FD002BBDB4 /* PassepartoutApp.swift */, - 0E7E3D5C2B9345FD002BBDB4 /* Assets.xcassets */, ); path = App; sourceTree = ""; diff --git a/Passepartout/Library/Package.swift b/Passepartout/Library/Package.swift index bc9e4cfa..37fa37c2 100644 --- a/Passepartout/Library/Package.swift +++ b/Passepartout/Library/Package.swift @@ -76,6 +76,7 @@ let package = Package( .target( name: "CommonLibrary", dependencies: [ + .product(name: "PassepartoutAPIBundle", package: "passepartoutkit-source"), .product(name: "PassepartoutKit", package: "passepartoutkit-source"), .product(name: "PassepartoutOpenVPNOpenSSL", package: "passepartoutkit-source-openvpn-openssl"), .product(name: "PassepartoutWireGuardGo", package: "passepartoutkit-source-wireguard-go") diff --git a/Passepartout/Library/Sources/AppUI/IAP/IAPManager+ProfileProcessor.swift b/Passepartout/Library/Sources/AppLibrary/Business/ProviderFactory.swift similarity index 50% rename from Passepartout/Library/Sources/AppUI/IAP/IAPManager+ProfileProcessor.swift rename to Passepartout/Library/Sources/AppLibrary/Business/ProviderFactory.swift index 856a88db..9f143e53 100644 --- a/Passepartout/Library/Sources/AppUI/IAP/IAPManager+ProfileProcessor.swift +++ b/Passepartout/Library/Sources/AppLibrary/Business/ProviderFactory.swift @@ -1,8 +1,8 @@ // -// IAPManager+ProfileProcessor.swift +// ProviderFactory.swift // Passepartout // -// Created by Davide De Rosa on 9/10/24. +// Created by Davide De Rosa on 10/8/24. // Copyright (c) 2024 Davide De Rosa. All rights reserved. // // https://github.com/passepartoutvpn @@ -26,23 +26,14 @@ import Foundation import PassepartoutKit -extension IAPManager: ProfileProcessor { - func processedProfile(_ profile: Profile) throws -> Profile { - var builder = profile.builder() +@MainActor +public final class ProviderFactory: ObservableObject { + public let providerManager: ProviderManager - // suppress on-demand rules if not eligible - if !isEligible(for: .onDemand) { - pp_log(.app, .notice, "Suppress on-demand rules, not eligible") + public let vpnProviderManager: VPNProviderManager - if let onDemandModuleIndex = builder.modules.firstIndex(where: { $0 is OnDemandModule }), - let onDemandModule = builder.modules[onDemandModuleIndex] as? OnDemandModule { - - var onDemandBuilder = onDemandModule.builder() - onDemandBuilder.policy = .any - builder.modules[onDemandModuleIndex] = onDemandBuilder.tryBuild() - } - } - - return try builder.tryBuild() + public init(providerManager: ProviderManager, vpnProviderManager: VPNProviderManager) { + self.providerManager = providerManager + self.vpnProviderManager = vpnProviderManager } } diff --git a/Passepartout/Library/Sources/AppUI/Business/AppContext.swift b/Passepartout/Library/Sources/AppUI/Business/AppContext.swift index 9472e610..8f680624 100644 --- a/Passepartout/Library/Sources/AppUI/Business/AppContext.swift +++ b/Passepartout/Library/Sources/AppUI/Business/AppContext.swift @@ -36,6 +36,8 @@ public final class AppContext: ObservableObject { public let profileManager: ProfileManager + public let profileProcessor: ProfileProcessor + public let tunnel: Tunnel public let tunnelEnvironment: TunnelEnvironment @@ -44,6 +46,8 @@ public final class AppContext: ObservableObject { public let registry: Registry + public let providerFactory: ProviderFactory + private let constants: Constants private var subscriptions: Set @@ -51,13 +55,16 @@ public final class AppContext: ObservableObject { public init( iapManager: IAPManager, profileManager: ProfileManager, + profileProcessor: ProfileProcessor, tunnel: Tunnel, tunnelEnvironment: TunnelEnvironment, registry: Registry, + providerFactory: ProviderFactory, constants: Constants ) { self.iapManager = iapManager self.profileManager = profileManager + self.profileProcessor = profileProcessor self.tunnel = tunnel self.tunnelEnvironment = tunnelEnvironment connectionObserver = ConnectionObserver( @@ -66,6 +73,7 @@ public final class AppContext: ObservableObject { interval: constants.connection.refreshInterval ) self.registry = registry + self.providerFactory = providerFactory self.constants = constants subscriptions = [] @@ -100,7 +108,7 @@ private extension AppContext { private extension AppContext { func installSavedProfile(_ profile: Profile) async throws { - try await tunnel.install(profile, processor: iapManager) + try await tunnel.install(profile, processor: profileProcessor) } func uninstallRemovedProfiles(withIds profileIds: [Profile.ID]) { @@ -125,7 +133,7 @@ private extension AppContext { return } if tunnel.status == .active { - try await tunnel.connect(with: profile, processor: iapManager) + try await tunnel.connect(with: profile, processor: profileProcessor) } } } diff --git a/Passepartout/Library/Sources/AppUI/Business/ProfileEditor.swift b/Passepartout/Library/Sources/AppUI/Business/ProfileEditor.swift index 039fb896..af28bc3d 100644 --- a/Passepartout/Library/Sources/AppUI/Business/ProfileEditor.swift +++ b/Passepartout/Library/Sources/AppUI/Business/ProfileEditor.swift @@ -93,37 +93,19 @@ extension ProfileEditor { } } -// MARK: - Metadata +// MARK: - Editing extension ProfileEditor { - var id: Profile.ID { - editableProfile.id - } - - var name: String { + var profile: EditableProfile { get { - editableProfile.name + editableProfile } set { - editableProfile.name = newValue + editableProfile = newValue } } - - func displayName(forModuleWithId moduleId: UUID) -> String? { - editableProfile.displayName(forModuleWithId: moduleId) - } - - func name(forModuleWithId moduleId: UUID) -> String? { - editableProfile.name(forModuleWithId: moduleId) - } - - func setName(_ name: String, forModuleWithId moduleId: UUID) { - editableProfile.setName(name, forModuleWithId: moduleId) - } } -// MARK: - Modules - extension ProfileEditor { var modules: [any ModuleBuilder] { editableProfile.modules diff --git a/Passepartout/Library/Sources/AppUI/Business/ProfileProcessor.swift b/Passepartout/Library/Sources/AppUI/Business/ProfileProcessor.swift index 2001fe50..75c594ab 100644 --- a/Passepartout/Library/Sources/AppUI/Business/ProfileProcessor.swift +++ b/Passepartout/Library/Sources/AppUI/Business/ProfileProcessor.swift @@ -26,6 +26,11 @@ import Foundation import PassepartoutKit -protocol ProfileProcessor { - func processedProfile(_ profile: Profile) throws -> Profile +@MainActor +public final class ProfileProcessor: ObservableObject { + public let process: (Profile) throws -> Profile + + public init(process: @escaping (Profile) throws -> Profile) { + self.process = process + } } diff --git a/Passepartout/Library/Sources/AppUI/Business/Tunnel+Extensions.swift b/Passepartout/Library/Sources/AppUI/Business/Tunnel+Extensions.swift index 12380c61..1951fe90 100644 --- a/Passepartout/Library/Sources/AppUI/Business/Tunnel+Extensions.swift +++ b/Passepartout/Library/Sources/AppUI/Business/Tunnel+Extensions.swift @@ -30,12 +30,12 @@ import PassepartoutKit @MainActor extension Tunnel { func install(_ profile: Profile, processor: ProfileProcessor) async throws { - let newProfile = try processor.processedProfile(profile) + let newProfile = try processor.process(profile) try await install(newProfile, connect: false, title: \.name) } func connect(with profile: Profile, processor: ProfileProcessor) async throws { - let newProfile = try processor.processedProfile(profile) + let newProfile = try processor.process(profile) try await install(newProfile, connect: true, title: \.name) } diff --git a/Passepartout/Library/Sources/AppUI/Domain/EditableProfile.swift b/Passepartout/Library/Sources/AppUI/Domain/EditableProfile.swift index ab3db0be..1d9df138 100644 --- a/Passepartout/Library/Sources/AppUI/Domain/EditableProfile.swift +++ b/Passepartout/Library/Sources/AppUI/Domain/EditableProfile.swift @@ -58,14 +58,10 @@ struct EditableProfile: MutableProfileType { builder.modulesMetadata = modulesMetadata?.reduce(into: [:]) { var metadata = $1.value - guard let name = metadata.name else { - return + if var trimmedName = metadata.name { + trimmedName = trimmedName.trimmingCharacters(in: .whitespaces) + metadata.name = !trimmedName.isEmpty ? trimmedName : nil } - let trimmedName = name.trimmingCharacters(in: .whitespaces) - guard !trimmedName.isEmpty else { - return - } - metadata.name = trimmedName $0[$1.key] = metadata } diff --git a/Passepartout/Library/Sources/AppUI/Domain/Issue.swift b/Passepartout/Library/Sources/AppUI/Domain/Issue.swift index 299c0ba0..48d32dac 100644 --- a/Passepartout/Library/Sources/AppUI/Domain/Issue.swift +++ b/Passepartout/Library/Sources/AppUI/Domain/Issue.swift @@ -81,7 +81,7 @@ struct Issue: Identifiable { .replacingOccurrences(of: "$appLine", with: appLine ?? "unknown") .replacingOccurrences(of: "$osLine", with: osLine) .replacingOccurrences(of: "$deviceLine", with: deviceLine ?? "unknown") - // FIXME: #614, replace with provider later + // FIXME: #703, report provider in issue .replacingOccurrences(of: "$providerName", with: "none") .replacingOccurrences(of: "$providerLastUpdate", with: "unknown") .replacingOccurrences(of: "$purchasedProducts", with: purchasedProducts.map(\.rawValue).description) diff --git a/Passepartout/Library/Sources/AppUI/L10n/EditableModule+Description.swift b/Passepartout/Library/Sources/AppUI/L10n/EditableModule+Description.swift index 32bd8cea..5a3904fd 100644 --- a/Passepartout/Library/Sources/AppUI/L10n/EditableModule+Description.swift +++ b/Passepartout/Library/Sources/AppUI/L10n/EditableModule+Description.swift @@ -30,7 +30,7 @@ extension ModuleBuilder { @MainActor func description(inEditor editor: ProfileEditor) -> String { - editor.displayName(forModuleWithId: id) ?? typeDescription + editor.profile.displayName(forModuleWithId: id) ?? typeDescription } } diff --git a/Passepartout/Library/Sources/AppUI/L10n/PassepartoutKit+L10n.swift b/Passepartout/Library/Sources/AppUI/L10n/PassepartoutKit+L10n.swift index 62a85189..e88c7d34 100644 --- a/Passepartout/Library/Sources/AppUI/L10n/PassepartoutKit+L10n.swift +++ b/Passepartout/Library/Sources/AppUI/L10n/PassepartoutKit+L10n.swift @@ -144,6 +144,31 @@ extension OnDemandModule.Policy: LocalizableEntity { } } +extension VPNServer { + public var sortableRegion: String { + [countryCodes.first?.localizedAsRegionCode, area] + .compactMap { $0 } + .joined(separator: " - ") + } + + public var sortableAddresses: String { + if let hostname { + return hostname + } + if let ipAddresses { + return ipAddresses + .compactMap { + guard let address = Address(data: $0) else { + return nil + } + return address.description + } + .joined(separator: ", ") + } + return "" + } +} + extension OpenVPN.Credentials.OTPMethod: StyledLocalizableEntity { public enum Style { case entity diff --git a/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift b/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift index 3f7ee8f8..51eeea44 100644 --- a/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift +++ b/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift @@ -243,6 +243,8 @@ public enum Strings { public static let profile = Strings.tr("Localizable", "global.profile", fallback: "Profile") /// Protocol public static let `protocol` = Strings.tr("Localizable", "global.protocol", fallback: "Protocol") + /// Provider + public static let provider = Strings.tr("Localizable", "global.provider", fallback: "Provider") /// Public key public static let publicKey = Strings.tr("Localizable", "global.public_key", fallback: "Public key") /// Purchase @@ -585,6 +587,12 @@ public enum Strings { public static let newProfile = Strings.tr("Localizable", "views.profiles.toolbar.new_profile", fallback: "New profile") } } + public enum Provider { + public enum Vpn { + /// Refresh infrastructure + public static let refreshInfrastructure = Strings.tr("Localizable", "views.provider.vpn.refresh_infrastructure", fallback: "Refresh infrastructure") + } + } public enum Settings { public enum Rows { /// Ask before quit diff --git a/Passepartout/Library/Sources/AppUI/Mock/Mock.swift b/Passepartout/Library/Sources/AppUI/Mock/Mock.swift index 84af50fd..82ca4e99 100644 --- a/Passepartout/Library/Sources/AppUI/Mock/Mock.swift +++ b/Passepartout/Library/Sources/AppUI/Mock/Mock.swift @@ -29,8 +29,6 @@ import Foundation import PassepartoutKit import UtilsLibrary -// MARK: AppContext - extension AppContext { public static let mock: AppContext = .mock(withRegistry: Registry()) @@ -56,9 +54,16 @@ extension AppContext { } return ProfileManager(profiles: profiles) }(), + profileProcessor: ProfileProcessor { + try $0.withProviderModules() + }, tunnel: Tunnel(strategy: FakeTunnelStrategy(environment: env)), tunnelEnvironment: env, registry: registry, + providerFactory: ProviderFactory( + providerManager: ProviderManager(repository: InMemoryProviderRepository()), + vpnProviderManager: VPNProviderManager(repository: InMemoryVPNProviderRepository()) + ), constants: .shared ) } @@ -76,6 +81,18 @@ extension ProfileManager { } } +extension ProviderFactory { + public static var mock: ProviderFactory { + AppContext.mock.providerFactory + } +} + +extension ProfileProcessor { + public static var mock: ProfileProcessor { + AppContext.mock.profileProcessor + } +} + extension Tunnel { public static var mock: Tunnel { AppContext.mock.tunnel diff --git a/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings b/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings index 9127ae11..f8770e2a 100644 --- a/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings @@ -47,6 +47,7 @@ "global.private_key" = "Private key"; "global.profile" = "Profile"; "global.protocol" = "Protocol"; +"global.provider" = "Provider"; "global.public_key" = "Public key"; "global.purchase" = "Purchase"; "global.remove" = "Delete"; @@ -127,6 +128,8 @@ "views.profile.rows.add_module" = "Add module"; "views.profile.module_list.section.footer" = "Drag modules to rearrange them, as their order determines priority."; +"views.provider.vpn.refresh_infrastructure" = "Refresh infrastructure"; + "views.settings.sections.icloud.footer" = "To erase the iCloud store securely, do so on all your synced devices. This will not affect local profiles."; "views.settings.rows.confirm_quit" = "Ask before quit"; "views.settings.rows.lock_in_background" = "Lock in background"; diff --git a/Passepartout/Library/Sources/AppUI/Views/Modules/DNSView.swift b/Passepartout/Library/Sources/AppUI/Views/Modules/DNSView.swift index 6c6a9bc8..599c66d5 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Modules/DNSView.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Modules/DNSView.swift @@ -30,7 +30,7 @@ import UtilsLibrary extension DNSModule.Builder: ModuleViewProviding { func moduleView(with editor: ProfileEditor) -> some View { - DNSView(editor: editor, original: self) + DNSView(editor: editor, module: self) } } @@ -45,9 +45,9 @@ private struct DNSView: View { @Binding private var draft: DNSModule.Builder - init(editor: ProfileEditor, original: DNSModule.Builder) { + init(editor: ProfileEditor, module: DNSModule.Builder) { self.editor = editor - _draft = editor.binding(forModule: original) + _draft = editor.binding(forModule: module) } var body: some View { @@ -62,7 +62,7 @@ private struct DNSView: View { .labelsHidden() } .themeManualInput() - .asModuleView(with: editor, draft: draft) + .moduleView(editor: editor, draft: draft) } } diff --git a/Passepartout/Library/Sources/AppUI/Views/Modules/HTTPProxyView.swift b/Passepartout/Library/Sources/AppUI/Views/Modules/HTTPProxyView.swift index 5fdcccd0..d8796a78 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Modules/HTTPProxyView.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Modules/HTTPProxyView.swift @@ -29,7 +29,7 @@ import UtilsLibrary extension HTTPProxyModule.Builder: ModuleViewProviding { func moduleView(with editor: ProfileEditor) -> some View { - HTTPProxyView(editor: editor, original: self) + HTTPProxyView(editor: editor, module: self) } } @@ -44,9 +44,9 @@ private struct HTTPProxyView: View { @Binding private var draft: HTTPProxyModule.Builder - init(editor: ProfileEditor, original: HTTPProxyModule.Builder) { + init(editor: ProfileEditor, module: HTTPProxyModule.Builder) { self.editor = editor - _draft = editor.binding(forModule: original) + _draft = editor.binding(forModule: module) } var body: some View { @@ -58,7 +58,7 @@ private struct HTTPProxyView: View { } .labelsHidden() .themeManualInput() - .asModuleView(with: editor, draft: draft) + .moduleView(editor: editor, draft: draft) } } diff --git a/Passepartout/Library/Sources/AppUI/Views/Modules/IPView+Route.swift b/Passepartout/Library/Sources/AppUI/Views/Modules/IPView+Route.swift index 967fe319..30747f14 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Modules/IPView+Route.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Modules/IPView+Route.swift @@ -111,7 +111,7 @@ private extension IPView.RouteView { Button("Add route") { isPresented = true } - .sheet(isPresented: $isPresented) { + .themeModal(isPresented: $isPresented) { NavigationStack { IPView.RouteView(family: .v4) { route = $0 diff --git a/Passepartout/Library/Sources/AppUI/Views/Modules/IPView.swift b/Passepartout/Library/Sources/AppUI/Views/Modules/IPView.swift index c416e508..ac0358bd 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Modules/IPView.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Modules/IPView.swift @@ -29,7 +29,7 @@ import UtilsLibrary extension IPModule.Builder: ModuleViewProviding { func moduleView(with editor: ProfileEditor) -> some View { - IPView(editor: editor, original: self) + IPView(editor: editor, module: self) } } @@ -44,9 +44,9 @@ struct IPView: View { @State private var routePresentation: RoutePresentation? - init(editor: ProfileEditor, original: IPModule.Builder) { + init(editor: ProfileEditor, module: IPModule.Builder) { self.editor = editor - _draft = editor.binding(forModule: original) + _draft = editor.binding(forModule: module) } var body: some View { @@ -55,7 +55,7 @@ struct IPView: View { ipSections(for: .v6) interfaceSection } - .asModuleView(with: editor, draft: draft) + .moduleView(editor: editor, draft: draft) .themeModal(item: $routePresentation, content: routeModal) } } diff --git a/Passepartout/Library/Sources/AppUI/Views/Modules/OnDemandView.swift b/Passepartout/Library/Sources/AppUI/Views/Modules/OnDemandView.swift index 439fe610..9c1da49c 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Modules/OnDemandView.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Modules/OnDemandView.swift @@ -29,7 +29,7 @@ import UtilsLibrary extension OnDemandModule.Builder: ModuleViewProviding { func moduleView(with editor: ProfileEditor) -> some View { - OnDemandView(editor: editor, original: self) + OnDemandView(editor: editor, module: self) } } @@ -54,12 +54,12 @@ private struct OnDemandView: View { init( editor: ProfileEditor, - original: OnDemandModule.Builder, + module: OnDemandModule.Builder, observer: WifiObserver? = nil ) { self.editor = editor wifi = Wifi(observer: observer ?? CoreLocationWifiObserver()) - _draft = editor.binding(forModule: original) + _draft = editor.binding(forModule: module) } var body: some View { @@ -67,7 +67,7 @@ private struct OnDemandView: View { enabledSection restrictedArea } - .asModuleView(with: editor, draft: draft) + .moduleView(editor: editor, draft: draft) .modifier(PaywallModifier(reason: $paywallReason)) } } @@ -257,7 +257,7 @@ private extension OnDemandView { return module.preview { OnDemandView( editor: $0, - original: $1, + module: $1, observer: MockWifi() ) } diff --git a/Passepartout/Library/Sources/AppUI/Views/Modules/OpenVPNView.swift b/Passepartout/Library/Sources/AppUI/Views/Modules/OpenVPNView.swift index 2ee34cdb..5c763230 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Modules/OpenVPNView.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Modules/OpenVPNView.swift @@ -28,7 +28,7 @@ import SwiftUI extension OpenVPNModule.Builder: ModuleViewProviding { func moduleView(with editor: ProfileEditor) -> some View { - OpenVPNView(editor: editor, original: self) + OpenVPNView(editor: editor, module: self) } } @@ -45,9 +45,6 @@ extension OpenVPNModule.Builder: InteractiveViewProviding { } struct OpenVPNView: View { - private enum Subroute: Hashable { - case credentials - } @ObservedObject private var editor: ProfileEditor @@ -57,71 +54,151 @@ struct OpenVPNView: View { @Binding private var draft: OpenVPNModule.Builder + @Binding + private var providerId: ProviderID? + + @Binding + private var providerEntity: VPNEntity? + init(serverConfiguration: OpenVPN.Configuration) { let module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder()) - editor = ProfileEditor(modules: [module]) + let editor = ProfileEditor(modules: [module]) + + self.editor = editor _draft = .constant(module) + _providerId = .constant(nil) + _providerEntity = .constant(nil) isServerPushed = true } - init(editor: ProfileEditor, original: OpenVPNModule.Builder) { + init(editor: ProfileEditor, module: OpenVPNModule.Builder) { self.editor = editor - _draft = editor.binding(forModule: original) + _draft = editor.binding(forModule: module) + _providerId = editor.binding(forProviderOf: module.id) + _providerEntity = editor.binding(forProviderEntityOf: module.id) isServerPushed = false } var body: some View { - Group { - 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) - } - moduleSection(for: redirectRows, header: Strings.Modules.Openvpn.redirectGateway) - moduleSection( - for: ipRows(for: configuration.ipv4, routes: configuration.routes4), - header: Strings.Unlocalized.ipv4 - ) - moduleSection( - for: ipRows(for: configuration.ipv6, routes: configuration.routes6), - header: Strings.Unlocalized.ipv6 - ) - moduleSection(for: dnsRows, header: Strings.Unlocalized.dns) - moduleSection(for: proxyRows, header: Strings.Unlocalized.proxy) - moduleSection(for: communicationRows, header: Strings.Modules.Openvpn.communication) - moduleSection(for: compressionRows, header: Strings.Modules.Openvpn.compression) - if !isServerPushed { - moduleSection(for: tlsRows, header: Strings.Unlocalized.tls) - } - moduleSection(for: otherRows, header: Strings.Global.other) - } - .asModuleView(with: editor, draft: draft, withName: !isServerPushed) - .navigationDestination(for: Subroute.self) { route in - switch route { - case .credentials: - CredentialsView( - isInteractive: $draft.isInteractive, - credentials: $draft.credentials - ) - } - } + debugChanges() + return contentView + .modifier(providerModifier) + .moduleView(editor: editor, draft: draft, withName: !isServerPushed) + .navigationDestination(for: Subroute.self, destination: destination) + .themeAnimation(on: providerId, category: .modules) } } +// MARK: - Content + private extension OpenVPNView { var configuration: OpenVPN.Configuration.Builder { draft.configurationBuilder } - var pullRows: [ModuleRow]? { - configuration.pullMask?.map { - .text(caption: $0.localizedDescription, value: nil) - } - .nilIfEmpty + var providerModifier: some ViewModifier { + ProviderPanelModifier( + providerId: $providerId, + selectedEntity: $providerEntity, + providerContent: providerContentView + ) } + @ViewBuilder + var contentView: some View { + credentialsView + if providerId == nil { + manualView + } + } + + @ViewBuilder + func providerContentView(providerId: ProviderID, entity: VPNEntity?) -> some View { + NavigationLink(value: Subroute.providerServer(id: providerId)) { + HStack { + Text("Server") + if let entity { + Spacer() + Text(entity.server.hostname ?? entity.server.serverId) + .foregroundStyle(.secondary) + } + } + } + credentialsView + } + + var credentialsView: some View { + moduleSection(for: accountRows, header: Strings.Global.account) + } + + @ViewBuilder + var manualView: some View { + moduleSection(for: remotesRows, header: Strings.Modules.Openvpn.remotes) + if !isServerPushed { + moduleSection(for: pullRows, header: Strings.Modules.Openvpn.pull) + } + moduleSection(for: redirectRows, header: Strings.Modules.Openvpn.redirectGateway) + moduleSection( + for: ipRows(for: configuration.ipv4, routes: configuration.routes4), + header: Strings.Unlocalized.ipv4 + ) + moduleSection( + for: ipRows(for: configuration.ipv6, routes: configuration.routes6), + header: Strings.Unlocalized.ipv6 + ) + moduleSection(for: dnsRows, header: Strings.Unlocalized.dns) + moduleSection(for: proxyRows, header: Strings.Unlocalized.proxy) + moduleSection(for: communicationRows, header: Strings.Modules.Openvpn.communication) + moduleSection(for: compressionRows, header: Strings.Modules.Openvpn.compression) + if !isServerPushed { + moduleSection(for: tlsRows, header: Strings.Unlocalized.tls) + } + moduleSection(for: otherRows, header: Strings.Global.other) + } +} + +private extension OpenVPNView { + func onSelect(server: VPNServer, preset: VPNPreset) { + providerEntity = VPNEntity(server: server, preset: preset) + } + + func importConfiguration(from url: URL) { + // TODO: #657, import draft from external URL + } +} + +// MARK: - Destinations + +private extension OpenVPNView { + enum Subroute: Hashable { + case providerServer(id: ProviderID) + + case credentials + } + + @ViewBuilder + func destination(for route: Subroute) -> some View { + switch route { + case .providerServer(let id): + VPNProviderServerView( + providerId: id, + onSelect: onSelect + ) + + case .credentials: + CredentialsView( + isInteractive: $draft.isInteractive, + credentials: $draft.credentials + ) + } + } +} + +// MARK: - Subviews + +private extension OpenVPNView { var accountRows: [ModuleRow]? { - guard configuration.authUserPass == true else { + guard configuration.authUserPass == true || providerId != nil else { return nil } return [.push(caption: Strings.Modules.Openvpn.credentials, route: HashableRoute(Subroute.credentials))] @@ -136,6 +213,13 @@ private extension OpenVPNView { .nilIfEmpty } + var pullRows: [ModuleRow]? { + configuration.pullMask?.map { + .text(caption: $0.localizedDescription, value: nil) + } + .nilIfEmpty + } + func ipRows(for ip: IPSettings?, routes: [Route]?) -> [ModuleRow]? { var rows: [ModuleRow] = [] if let ip { @@ -319,12 +403,6 @@ private extension OpenVPNView { } } -private extension OpenVPNView { - func importConfiguration(from url: URL) { - // TODO: #657, import draft from external URL - } -} - // MARK: - Previews // swiftlint: disable force_try diff --git a/Passepartout/Library/Sources/AppUI/Views/Modules/WireGuardView.swift b/Passepartout/Library/Sources/AppUI/Views/Modules/WireGuardView.swift index 9b2bc545..85c98961 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Modules/WireGuardView.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Modules/WireGuardView.swift @@ -29,11 +29,14 @@ import SwiftUI extension WireGuardModule.Builder: ModuleViewProviding { func moduleView(with editor: ProfileEditor) -> some View { - WireGuardView(editor: editor, original: self) + WireGuardView(editor: editor, module: self) } } private struct WireGuardView: View { + private enum Subroute: Hashable { + case providerServer(id: ProviderID) + } @ObservedObject private var editor: ProfileEditor @@ -41,28 +44,64 @@ private struct WireGuardView: View { @Binding private var draft: WireGuardModule.Builder - init(editor: ProfileEditor, original: WireGuardModule.Builder) { +// @Binding +// private var providerId: ProviderID? +// +// @State +// private var providerServer: VPNServer? + + init(editor: ProfileEditor, module: WireGuardModule.Builder) { self.editor = editor - _draft = editor.binding(forModule: original) + _draft = editor.binding(forModule: module) +// _providerId = editor.binding(forProviderOf: module.id) } var body: some View { - Group { - moduleSection(for: interfaceRows, header: Strings.Modules.Wireguard.interface) - moduleSection(for: dnsRows, header: Strings.Unlocalized.dns) - ForEach(Array(zip(configuration.peers.indices, configuration.peers)), id: \.1.publicKey) { index, peer in - moduleSection(for: peersRows(for: peer), header: Strings.Modules.Wireguard.peer(index + 1)) - } - } - .asModuleView(with: editor, draft: draft) + contentView +// .modifier(providerModifier) + .moduleView(editor: editor, draft: draft) +// .navigationDestination(for: Subroute.self) { +// switch $0 { +// case .providerServer(let id): +// VPNProviderServerView(providerId: id) { +// providerServer = $1 +// } +// } +// } } } +// MARK: - Content + private extension WireGuardView { var configuration: WireGuard.Configuration.Builder { draft.configurationBuilder } + @ViewBuilder + var contentView: some View { + moduleSection(for: interfaceRows, header: Strings.Modules.Wireguard.interface) + moduleSection(for: dnsRows, header: Strings.Unlocalized.dns) + ForEach(Array(zip(configuration.peers.indices, configuration.peers)), id: \.1.publicKey) { index, peer in + moduleSection(for: peersRows(for: peer), header: Strings.Modules.Wireguard.peer(index + 1)) + } + } + +// var providerModifier: some ViewModifier { +// ProviderPanelModifier( +// providerId: $providerId, +// selectedServer: $providerServer, +// configurationType: WireGuard.Configuration.self, +// serverRoute: { +// Subroute.providerServer(id: $0) +// } +// ) +// } +} + +// MARK: - Subviews + +private extension WireGuardView { var interfaceRows: [ModuleRow]? { var rows: [ModuleRow] = [] rows.append(.longContent(caption: Strings.Global.privateKey, value: configuration.interface.privateKey)) diff --git a/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift b/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift index 5159102e..c390c0e5 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift @@ -49,7 +49,7 @@ struct ProfileEditView: View, Routable { debugChanges() return List { NameSection( - name: $profileEditor.name, + name: $profileEditor.profile.name, placeholder: Strings.Placeholders.Profile.name ) Group { diff --git a/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ModuleListView+macOS.swift b/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ModuleListView+macOS.swift index b406a9ea..a1af421d 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ModuleListView+macOS.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ModuleListView+macOS.swift @@ -64,7 +64,7 @@ struct ModuleListView: View, Routable { } .onDeleteCommand(perform: removeSelectedModule) .toolbar(content: toolbarContent) - .navigationTitle(profileEditor.name) + .navigationTitle(profileEditor.profile.name) } } diff --git a/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ProfileGeneralView+macOS.swift b/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ProfileGeneralView+macOS.swift index a56427c1..184bb9cf 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ProfileGeneralView+macOS.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ProfileGeneralView+macOS.swift @@ -35,7 +35,7 @@ struct ProfileGeneralView: View { var body: some View { Form { NameSection( - name: $profileEditor.name, + name: $profileEditor.profile.name, placeholder: Strings.Placeholders.Profile.name ) StorageSection( diff --git a/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/DefaultModuleViewFactory.swift b/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/DefaultModuleViewFactory.swift index 3659afb6..7c154ab9 100644 --- a/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/DefaultModuleViewFactory.swift +++ b/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/DefaultModuleViewFactory.swift @@ -52,22 +52,3 @@ private extension ProfileEditor { return (provider, module.typeDescription) } } - -extension View { - - @MainActor - func asModuleView(with editor: ProfileEditor, draft: T, withName: Bool = true) -> some View where T: ModuleBuilder, T: Equatable { - Form { - if withName { - NameSection( - name: editor.binding(forNameOf: draft.id), - placeholder: draft.typeDescription - ) - } - self - } - .themeForm() - .themeManualInput() - .themeAnimation(on: draft, category: .modules) - } -} diff --git a/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ModuleSection.swift b/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ModuleSection.swift index 8c9b8392..19608aaa 100644 --- a/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ModuleSection.swift +++ b/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ModuleSection.swift @@ -70,12 +70,6 @@ struct HashableRoute: Hashable { } } -extension Collection { - var nilIfEmpty: [Element]? { - !isEmpty ? Array(self) : nil - } -} - extension View { func moduleSection(for rows: [ModuleRow]?, header: String) -> some View { rows.map { rows in diff --git a/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ModuleViewModifier.swift b/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ModuleViewModifier.swift new file mode 100644 index 00000000..776e1668 --- /dev/null +++ b/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ModuleViewModifier.swift @@ -0,0 +1,58 @@ +// +// ModuleViewModifier.swift +// Passepartout +// +// Created by Davide De Rosa on 10/9/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 . +// + +import PassepartoutKit +import SwiftUI + +struct ModuleViewModifier: ViewModifier where T: ModuleBuilder & Equatable { + + @ObservedObject + var editor: ProfileEditor + + let draft: T + + let withName: Bool + + func body(content: Content) -> some View { + Form { + if withName { + NameSection( + name: editor.binding(forNameOf: draft.id), + placeholder: draft.typeDescription + ) + } + content + } + .themeForm() + .themeManualInput() + .themeAnimation(on: draft, category: .modules) + } +} + +extension View { + func moduleView(editor: ProfileEditor, draft: T, withName: Bool = true) -> some View where T: ModuleBuilder & Equatable { + modifier(ModuleViewModifier(editor: editor, draft: draft, withName: withName)) + } +} diff --git a/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ProfileEditor+UI.swift b/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ProfileEditor+UI.swift index bb2d2f45..a0bbbb62 100644 --- a/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ProfileEditor+UI.swift +++ b/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ProfileEditor+UI.swift @@ -29,9 +29,25 @@ import SwiftUI extension ProfileEditor { func binding(forNameOf moduleId: UUID) -> Binding { Binding { [weak self] in - self?.name(forModuleWithId: moduleId) ?? "" + self?.profile.name(forModuleWithId: moduleId) ?? "" } set: { [weak self] in - self?.setName($0, forModuleWithId: moduleId) + self?.profile.setName($0, forModuleWithId: moduleId) + } + } + + func binding(forProviderOf moduleId: UUID) -> Binding { + Binding { [weak self] in + self?.profile.providerId(forModuleWithId: moduleId) + } set: { [weak self] in + self?.profile.setProviderId($0, forModuleWithId: moduleId) + } + } + + func binding(forProviderEntityOf moduleId: UUID) -> Binding where E: ProviderEntity & Codable { + Binding { [weak self] in + try? self?.profile.providerEntity(E.self, forModuleWithId: moduleId) + } set: { [weak self] in + try? self?.profile.setProviderEntity($0, forModuleWithId: moduleId) } } diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/ProviderPanelModifier.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/ProviderPanelModifier.swift new file mode 100644 index 00000000..2c4c4bf2 --- /dev/null +++ b/Passepartout/Library/Sources/AppUI/Views/Provider/ProviderPanelModifier.swift @@ -0,0 +1,135 @@ +// +// ProviderPanelModifier.swift +// Passepartout +// +// Created by Davide De Rosa on 10/7/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 . +// + +import AppLibrary +import PassepartoutKit +import SwiftUI + +struct ProviderPanelModifier: ViewModifier where Entity: ProviderEntity, Entity.Configuration: ProviderConfigurationIdentifiable & Codable, ProviderContent: View { + + @EnvironmentObject + private var providerManager: ProviderManager + + var apis: [APIMapper] = API.shared + + @Binding + var providerId: ProviderID? + + @Binding + var selectedEntity: Entity? + + @ViewBuilder + let providerContent: (ProviderID, Entity?) -> ProviderContent + + func body(content: Content) -> some View { + providerPicker + .task { + await refreshIndex() + } + + if let providerId { + providerContent(providerId, selectedEntity) + } else { + content + } + } +} + +private extension ProviderPanelModifier { + var supportedProviders: [ProviderMetadata] { + providerManager.providers.filter { + $0.supports(Entity.Configuration.self) + } + } + + var providersPlusEmpty: [ProviderMetadata] { + [ProviderMetadata("", description: Strings.Global.none)] + supportedProviders + } + + var providerPicker: some View { + let hasProviders = !supportedProviders.isEmpty + return Picker(Strings.Global.provider, selection: $providerId) { + if hasProviders { + ForEach(providersPlusEmpty, id: \.id) { + Text($0.description) + .tag($0.id.nilIfEmpty) + } + } else { + Text(" ") // enforce constant picker height on iOS + .tag(providerId) // tag always exists + } + } + .onChange(of: providerId) { _ in + selectedEntity = nil + } + .disabled(!hasProviders) + } +} + +private extension ProviderPanelModifier { + + // FIXME: #707, fetch bundled providers on launch + // FIXME: #704, rate-limit fetch + func refreshIndex() async { + do { + try await providerManager.fetchIndex(from: apis) + } catch { + pp_log(.app, .error, "Unable to fetch index: \(error)") + } + } +} + +private extension ProviderID { + var nilIfEmpty: ProviderID? { + !rawValue.isEmpty ? self : nil + } +} + +// MARK: - Preview + +#Preview { + @State + var providerId: ProviderID? = .hideme + + @State + var vpnEntity: VPNEntity? + + return List { + EmptyView() + .modifier(ProviderPanelModifier( + apis: [API.bundled], + providerId: $providerId, + selectedEntity: $vpnEntity, + providerContent: { id, entity in + HStack { + Text("Server") + Spacer() + Text(entity?.server.serverId ?? "None") + } + } + )) + } + .withMockEnvironment() +} diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersModifier.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersModifier.swift new file mode 100644 index 00000000..16a2daed --- /dev/null +++ b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersModifier.swift @@ -0,0 +1,49 @@ +// +// VPNFiltersModifier.swift +// Passepartout +// +// Created by Davide De Rosa on 10/9/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 . +// + +import PassepartoutKit +import SwiftUI + +struct VPNFiltersModifier: ViewModifier where Configuration: Decodable { + + @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 + Task { + await manager.applyFilters() + } + } + } +} diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersView.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersView.swift new file mode 100644 index 00000000..5d9ded5c --- /dev/null +++ b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersView.swift @@ -0,0 +1,180 @@ +// +// VPNFiltersView.swift +// Passepartout +// +// Created by Davide De Rosa on 10/9/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 . +// + +import AppLibrary +import PassepartoutKit +import SwiftUI + +// FIXME: #703, providers UI + +struct VPNFiltersView: View where Configuration: Decodable { + + @ObservedObject + var manager: VPNProviderManager + + let providerId: ProviderID + + let onRefresh: () async -> Void + + @State + private var isRefreshing = false + + var body: some View { + Form { + Section { + categoryPicker + countryPicker + presetPicker +#if os(iOS) + clearFiltersButton + .frame(maxWidth: .infinity, alignment: .center) +#else + HStack { + Spacer() + clearFiltersButton + refreshButton + } +#endif + } +#if os(iOS) + Section { + refreshButton + } +#endif + } + } +} + +private extension VPNFiltersView { + var categoryPicker: some View { + Picker("Category", selection: $manager.filters.categoryName) { + Text("Any") + .tag(nil as String?) + ForEach(categories, id: \.self) { + Text($0.capitalized) + .tag($0 as String?) + } + } + } + + var countryPicker: some View { + Picker("Country", selection: $manager.filters.countryCode) { + Text("Any") + .tag(nil as String?) + ForEach(countries, id: \.code) { + Text($0.description) + .tag($0.code as String?) + } + } + } + + @ViewBuilder + var presetPicker: some View { + if manager.anyPresets.count > 1 { + Picker("Preset", selection: $manager.filters.presetId) { + Text("Any") + .tag(nil as String?) + ForEach(presets, id: \.presetId) { + Text($0.description) + .tag($0.presetId as String?) + } + } + } + } + + var clearFiltersButton: some View { + Button("Clear filters", role: .destructive) { + Task { + await manager.resetFilters() + } + } + } + + 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 { + var categories: [String] { + let allCategories = manager + .allServers + .values + .map(\.provider.categoryName) + + return Set(allCategories) + .sorted() + } + + var countries: [(code: String, description: String)] { + let allCodes = manager + .allServers + .values + .flatMap(\.countryCodes) + + return Set(allCodes) + .map { + (code: $0, description: $0.localizedAsRegionCode ?? $0) + } + .sorted { + $0.description < $1.description + } + } + + var presets: [VPNPreset] { + manager + .presets(ofType: Configuration.self) + .sorted { + $0.description < $1.description + } + } +} + +#Preview { + NavigationStack { + VPNFiltersView( + manager: ProviderFactory.mock.vpnProviderManager, + providerId: .hideme, + onRefresh: {} + ) + } +} diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderServerView.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderServerView.swift new file mode 100644 index 00000000..6ef9b0aa --- /dev/null +++ b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderServerView.swift @@ -0,0 +1,148 @@ +// +// VPNProviderServerView.swift +// Passepartout +// +// Created by Davide De Rosa on 10/7/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 . +// + +import AppLibrary +import PassepartoutKit +import SwiftUI + +struct VPNProviderServerView: 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 + + let onSelect: (_ server: VPNServer, _ preset: VPNPreset) -> 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( + 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) + } +} + +// 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 + return + } + onSelect(server, preset) + dismiss() + } +} + +private extension VPNProviderServerView { + func compatiblePreset(with server: VPNServer) -> VPNPreset? { + vpnProviderManager + .presets(ofType: Configuration.self) + .first { + if let supportedIds = server.provider.supportedPresetIds { + return supportedIds.contains($0.presetId) + } + 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(apis: [API.bundled], providerId: .protonvpn) { _, _ in + } + } + .withMockEnvironment() +} diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/iOS/VPNFiltersModifier+iOS.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/iOS/VPNFiltersModifier+iOS.swift new file mode 100644 index 00000000..9835b676 --- /dev/null +++ b/Passepartout/Library/Sources/AppUI/Views/Provider/iOS/VPNFiltersModifier+iOS.swift @@ -0,0 +1,55 @@ +// +// VPNFiltersModifier+iOS.swift +// Passepartout +// +// Created by Davide De Rosa on 10/9/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 . +// + +#if os(iOS) + +import SwiftUI + +// FIXME: #703, providers UI + +extension VPNFiltersModifier { + func contentView(with content: Content) -> some View { + List { + content + } + .toolbar { + Button { + isFiltersPresented = true + } label: { + Image(systemName: "line.3.horizontal.decrease") + } + } + .themeModal(isPresented: $isFiltersPresented) { + NavigationStack { + VPNFiltersView(manager: manager, providerId: providerId, onRefresh: onRefresh) + .navigationTitle("Filters") + .navigationBarTitleDisplayMode(.inline) + } + .presentationDetents([.medium]) + } + } +} + +#endif diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/iOS/VPNProviderServerView+iOS.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/iOS/VPNProviderServerView+iOS.swift new file mode 100644 index 00000000..b4f8f7bf --- /dev/null +++ b/Passepartout/Library/Sources/AppUI/Views/Provider/iOS/VPNProviderServerView+iOS.swift @@ -0,0 +1,44 @@ +// +// VPNProviderServerView+iOS.swift +// Passepartout +// +// Created by Davide De Rosa on 10/9/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 . +// + +#if os(iOS) + +import SwiftUI + +// FIXME: #703, providers UI + +extension VPNProviderServerView { + var serversView: some View { + sortedServers.nilIfEmpty.map { servers in + ForEach(sortedServers) { server in + Button("\(server.hostname ?? server.id) \(server.countryCodes)") { + selectServer(server) + } + } + } + } +} + +#endif diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/macOS/VPNFiltersModifier+macOS.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/macOS/VPNFiltersModifier+macOS.swift new file mode 100644 index 00000000..8ac9e1db --- /dev/null +++ b/Passepartout/Library/Sources/AppUI/Views/Provider/macOS/VPNFiltersModifier+macOS.swift @@ -0,0 +1,40 @@ +// +// VPNFiltersModifier+macOS.swift +// Passepartout +// +// Created by Davide De Rosa on 10/9/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 . +// + +#if os(macOS) + +import SwiftUI + +extension VPNFiltersModifier { + func contentView(with content: Content) -> some View { + VStack { + VPNFiltersView(manager: manager, providerId: providerId, onRefresh: onRefresh) + .padding() + content + } + } +} + +#endif diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/macOS/VPNProviderServerView+macOS.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/macOS/VPNProviderServerView+macOS.swift new file mode 100644 index 00000000..29116da1 --- /dev/null +++ b/Passepartout/Library/Sources/AppUI/Views/Provider/macOS/VPNProviderServerView+macOS.swift @@ -0,0 +1,52 @@ +// +// VPNProviderServerView+macOS.swift +// Passepartout +// +// Created by Davide De Rosa on 10/9/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 . +// + +#if os(macOS) + +import SwiftUI + +// FIXME: #703, providers UI + +extension VPNProviderServerView { + var serversView: some View { + Table(sortedServers, sortOrder: $sortOrder) { + TableColumn("Region", value: \.sortableRegion) + .width(max: 200.0) + + TableColumn("Address", value: \.sortableAddresses) + + TableColumn("", value: \.serverId) { server in + Button { + selectServer(server) + } label: { + Text("Select") + } + } + .width(min: 100.0, max: 100.0) + } + } +} + +#endif diff --git a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+UI.swift b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+UI.swift index bf606e4a..d87b2696 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+UI.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+UI.swift @@ -329,13 +329,15 @@ struct ThemeTipModifier: ViewModifier { // MARK: - Views public enum ThemeAnimationCategory: CaseIterable { + case diagnostics + + case modules + case profiles case profilesLayout - case modules - - case diagnostics + case providers } struct ThemeImage: View { diff --git a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+iOS.swift b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+iOS.swift index b1ac4ad2..6d922b47 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+iOS.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+iOS.swift @@ -30,7 +30,7 @@ import SwiftUI extension Theme { public convenience init() { self.init(dummy: ()) - animationCategories = [.profiles, .modules, .diagnostics] + animationCategories = [.diagnostics, .modules, .profiles, .providers] } } diff --git a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+macOS.swift b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+macOS.swift index 9797713b..9ccfb67d 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+macOS.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+macOS.swift @@ -32,7 +32,7 @@ extension Theme { self.init(dummy: Void()) rootModalSize = CGSize(width: 700, height: 400) secondaryModalSize = CGSize(width: 500.0, height: 200.0) - animationCategories = [.profiles, .diagnostics] + animationCategories = [.diagnostics, .profiles, .providers] } } diff --git a/Passepartout/Library/Sources/AppUI/Views/UI/ConnectionStatusView.swift b/Passepartout/Library/Sources/AppUI/Views/UI/ConnectionStatusView.swift index 6a4d72d5..095c5cbe 100644 --- a/Passepartout/Library/Sources/AppUI/Views/UI/ConnectionStatusView.swift +++ b/Passepartout/Library/Sources/AppUI/Views/UI/ConnectionStatusView.swift @@ -75,7 +75,7 @@ private extension ConnectionStatusView { #Preview("Connected") { ConnectionStatusView(tunnel: .mock) .task { - try? await Tunnel.mock.connect(with: .mock, processor: IAPManager.mock) + try? await Tunnel.mock.connect(with: .mock, processor: .mock) } .frame(width: 100, height: 100) .withMockEnvironment() @@ -94,7 +94,7 @@ private extension ConnectionStatusView { } return ConnectionStatusView(tunnel: .mock) .task { - try? await Tunnel.mock.connect(with: profile, processor: IAPManager.mock) + try? await Tunnel.mock.connect(with: profile, processor: .mock) } .frame(width: 100, height: 100) .withMockEnvironment() diff --git a/Passepartout/Library/Sources/AppUI/Views/UI/StorageSection.swift b/Passepartout/Library/Sources/AppUI/Views/UI/StorageSection.swift index 0c252157..422ad7fb 100644 --- a/Passepartout/Library/Sources/AppUI/Views/UI/StorageSection.swift +++ b/Passepartout/Library/Sources/AppUI/Views/UI/StorageSection.swift @@ -43,7 +43,7 @@ struct StorageSection: View { sharingToggle ThemeCopiableText( title: Strings.Unlocalized.uuid, - value: profileEditor.id.flatString.localizedDescription(style: .quartets), + value: profileEditor.profile.id.flatString.localizedDescription(style: .quartets), valueView: { Text($0) .monospaced() diff --git a/Passepartout/Library/Sources/AppUI/Views/UI/TunnelRestartButton.swift b/Passepartout/Library/Sources/AppUI/Views/UI/TunnelRestartButton.swift index d65d13be..06ad0c50 100644 --- a/Passepartout/Library/Sources/AppUI/Views/UI/TunnelRestartButton.swift +++ b/Passepartout/Library/Sources/AppUI/Views/UI/TunnelRestartButton.swift @@ -30,7 +30,7 @@ import UtilsLibrary struct TunnelRestartButton