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()
This commit is contained in:
Davide 2024-10-11 00:24:06 +02:00 committed by GitHub
parent 5c91eb4bf1
commit da87ca698a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 1237 additions and 194 deletions

View File

@ -168,9 +168,9 @@
0E7E3D5B2B9345FD002BBDB4 /* App.entitlements */, 0E7E3D5B2B9345FD002BBDB4 /* App.entitlements */,
0E7C3CCC2C9AF44600B72E69 /* AppDelegate.swift */, 0E7C3CCC2C9AF44600B72E69 /* AppDelegate.swift */,
0EB08B962CA46F4900A02591 /* AppPlist.strings */, 0EB08B962CA46F4900A02591 /* AppPlist.strings */,
0E7E3D5C2B9345FD002BBDB4 /* Assets.xcassets */,
0EC066D02C7DC47600D88A94 /* LaunchScreen.storyboard */, 0EC066D02C7DC47600D88A94 /* LaunchScreen.storyboard */,
0E7E3D5F2B9345FD002BBDB4 /* PassepartoutApp.swift */, 0E7E3D5F2B9345FD002BBDB4 /* PassepartoutApp.swift */,
0E7E3D5C2B9345FD002BBDB4 /* Assets.xcassets */,
); );
path = App; path = App;
sourceTree = "<group>"; sourceTree = "<group>";

View File

@ -76,6 +76,7 @@ let package = Package(
.target( .target(
name: "CommonLibrary", name: "CommonLibrary",
dependencies: [ dependencies: [
.product(name: "PassepartoutAPIBundle", package: "passepartoutkit-source"),
.product(name: "PassepartoutKit", package: "passepartoutkit-source"), .product(name: "PassepartoutKit", package: "passepartoutkit-source"),
.product(name: "PassepartoutOpenVPNOpenSSL", package: "passepartoutkit-source-openvpn-openssl"), .product(name: "PassepartoutOpenVPNOpenSSL", package: "passepartoutkit-source-openvpn-openssl"),
.product(name: "PassepartoutWireGuardGo", package: "passepartoutkit-source-wireguard-go") .product(name: "PassepartoutWireGuardGo", package: "passepartoutkit-source-wireguard-go")

View File

@ -1,8 +1,8 @@
// //
// IAPManager+ProfileProcessor.swift // ProviderFactory.swift
// Passepartout // 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. // Copyright (c) 2024 Davide De Rosa. All rights reserved.
// //
// https://github.com/passepartoutvpn // https://github.com/passepartoutvpn
@ -26,23 +26,14 @@
import Foundation import Foundation
import PassepartoutKit import PassepartoutKit
extension IAPManager: ProfileProcessor { @MainActor
func processedProfile(_ profile: Profile) throws -> Profile { public final class ProviderFactory: ObservableObject {
var builder = profile.builder() public let providerManager: ProviderManager
// suppress on-demand rules if not eligible public let vpnProviderManager: VPNProviderManager
if !isEligible(for: .onDemand) {
pp_log(.app, .notice, "Suppress on-demand rules, not eligible")
if let onDemandModuleIndex = builder.modules.firstIndex(where: { $0 is OnDemandModule }), public init(providerManager: ProviderManager, vpnProviderManager: VPNProviderManager) {
let onDemandModule = builder.modules[onDemandModuleIndex] as? OnDemandModule { self.providerManager = providerManager
self.vpnProviderManager = vpnProviderManager
var onDemandBuilder = onDemandModule.builder()
onDemandBuilder.policy = .any
builder.modules[onDemandModuleIndex] = onDemandBuilder.tryBuild()
}
}
return try builder.tryBuild()
} }
} }

View File

@ -36,6 +36,8 @@ public final class AppContext: ObservableObject {
public let profileManager: ProfileManager public let profileManager: ProfileManager
public let profileProcessor: ProfileProcessor
public let tunnel: Tunnel public let tunnel: Tunnel
public let tunnelEnvironment: TunnelEnvironment public let tunnelEnvironment: TunnelEnvironment
@ -44,6 +46,8 @@ public final class AppContext: ObservableObject {
public let registry: Registry public let registry: Registry
public let providerFactory: ProviderFactory
private let constants: Constants private let constants: Constants
private var subscriptions: Set<AnyCancellable> private var subscriptions: Set<AnyCancellable>
@ -51,13 +55,16 @@ public final class AppContext: ObservableObject {
public init( public init(
iapManager: IAPManager, iapManager: IAPManager,
profileManager: ProfileManager, profileManager: ProfileManager,
profileProcessor: ProfileProcessor,
tunnel: Tunnel, tunnel: Tunnel,
tunnelEnvironment: TunnelEnvironment, tunnelEnvironment: TunnelEnvironment,
registry: Registry, registry: Registry,
providerFactory: ProviderFactory,
constants: Constants constants: Constants
) { ) {
self.iapManager = iapManager self.iapManager = iapManager
self.profileManager = profileManager self.profileManager = profileManager
self.profileProcessor = profileProcessor
self.tunnel = tunnel self.tunnel = tunnel
self.tunnelEnvironment = tunnelEnvironment self.tunnelEnvironment = tunnelEnvironment
connectionObserver = ConnectionObserver( connectionObserver = ConnectionObserver(
@ -66,6 +73,7 @@ public final class AppContext: ObservableObject {
interval: constants.connection.refreshInterval interval: constants.connection.refreshInterval
) )
self.registry = registry self.registry = registry
self.providerFactory = providerFactory
self.constants = constants self.constants = constants
subscriptions = [] subscriptions = []
@ -100,7 +108,7 @@ private extension AppContext {
private extension AppContext { private extension AppContext {
func installSavedProfile(_ profile: Profile) async throws { 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]) { func uninstallRemovedProfiles(withIds profileIds: [Profile.ID]) {
@ -125,7 +133,7 @@ private extension AppContext {
return return
} }
if tunnel.status == .active { if tunnel.status == .active {
try await tunnel.connect(with: profile, processor: iapManager) try await tunnel.connect(with: profile, processor: profileProcessor)
} }
} }
} }

View File

@ -93,37 +93,19 @@ extension ProfileEditor {
} }
} }
// MARK: - Metadata // MARK: - Editing
extension ProfileEditor { extension ProfileEditor {
var id: Profile.ID { var profile: EditableProfile {
editableProfile.id
}
var name: String {
get { get {
editableProfile.name editableProfile
} }
set { 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 { extension ProfileEditor {
var modules: [any ModuleBuilder] { var modules: [any ModuleBuilder] {
editableProfile.modules editableProfile.modules

View File

@ -26,6 +26,11 @@
import Foundation import Foundation
import PassepartoutKit import PassepartoutKit
protocol ProfileProcessor { @MainActor
func processedProfile(_ profile: Profile) throws -> Profile public final class ProfileProcessor: ObservableObject {
public let process: (Profile) throws -> Profile
public init(process: @escaping (Profile) throws -> Profile) {
self.process = process
}
} }

View File

@ -30,12 +30,12 @@ import PassepartoutKit
@MainActor @MainActor
extension Tunnel { extension Tunnel {
func install(_ profile: Profile, processor: ProfileProcessor) async throws { 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) try await install(newProfile, connect: false, title: \.name)
} }
func connect(with profile: Profile, processor: ProfileProcessor) async throws { 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) try await install(newProfile, connect: true, title: \.name)
} }

View File

@ -58,14 +58,10 @@ struct EditableProfile: MutableProfileType {
builder.modulesMetadata = modulesMetadata?.reduce(into: [:]) { builder.modulesMetadata = modulesMetadata?.reduce(into: [:]) {
var metadata = $1.value var metadata = $1.value
guard let name = metadata.name else { if var trimmedName = metadata.name {
return 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 $0[$1.key] = metadata
} }

View File

@ -81,7 +81,7 @@ struct Issue: Identifiable {
.replacingOccurrences(of: "$appLine", with: appLine ?? "unknown") .replacingOccurrences(of: "$appLine", with: appLine ?? "unknown")
.replacingOccurrences(of: "$osLine", with: osLine) .replacingOccurrences(of: "$osLine", with: osLine)
.replacingOccurrences(of: "$deviceLine", with: deviceLine ?? "unknown") .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: "$providerName", with: "none")
.replacingOccurrences(of: "$providerLastUpdate", with: "unknown") .replacingOccurrences(of: "$providerLastUpdate", with: "unknown")
.replacingOccurrences(of: "$purchasedProducts", with: purchasedProducts.map(\.rawValue).description) .replacingOccurrences(of: "$purchasedProducts", with: purchasedProducts.map(\.rawValue).description)

View File

@ -30,7 +30,7 @@ extension ModuleBuilder {
@MainActor @MainActor
func description(inEditor editor: ProfileEditor) -> String { func description(inEditor editor: ProfileEditor) -> String {
editor.displayName(forModuleWithId: id) ?? typeDescription editor.profile.displayName(forModuleWithId: id) ?? typeDescription
} }
} }

View File

@ -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 { extension OpenVPN.Credentials.OTPMethod: StyledLocalizableEntity {
public enum Style { public enum Style {
case entity case entity

View File

@ -243,6 +243,8 @@ public enum Strings {
public static let profile = Strings.tr("Localizable", "global.profile", fallback: "Profile") public static let profile = Strings.tr("Localizable", "global.profile", fallback: "Profile")
/// Protocol /// Protocol
public static let `protocol` = Strings.tr("Localizable", "global.protocol", fallback: "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 key
public static let publicKey = Strings.tr("Localizable", "global.public_key", fallback: "Public key") public static let publicKey = Strings.tr("Localizable", "global.public_key", fallback: "Public key")
/// Purchase /// Purchase
@ -585,6 +587,12 @@ public enum Strings {
public static let newProfile = Strings.tr("Localizable", "views.profiles.toolbar.new_profile", fallback: "New profile") 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 Settings {
public enum Rows { public enum Rows {
/// Ask before quit /// Ask before quit

View File

@ -29,8 +29,6 @@ import Foundation
import PassepartoutKit import PassepartoutKit
import UtilsLibrary import UtilsLibrary
// MARK: AppContext
extension AppContext { extension AppContext {
public static let mock: AppContext = .mock(withRegistry: Registry()) public static let mock: AppContext = .mock(withRegistry: Registry())
@ -56,9 +54,16 @@ extension AppContext {
} }
return ProfileManager(profiles: profiles) return ProfileManager(profiles: profiles)
}(), }(),
profileProcessor: ProfileProcessor {
try $0.withProviderModules()
},
tunnel: Tunnel(strategy: FakeTunnelStrategy(environment: env)), tunnel: Tunnel(strategy: FakeTunnelStrategy(environment: env)),
tunnelEnvironment: env, tunnelEnvironment: env,
registry: registry, registry: registry,
providerFactory: ProviderFactory(
providerManager: ProviderManager(repository: InMemoryProviderRepository()),
vpnProviderManager: VPNProviderManager(repository: InMemoryVPNProviderRepository())
),
constants: .shared 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 { extension Tunnel {
public static var mock: Tunnel { public static var mock: Tunnel {
AppContext.mock.tunnel AppContext.mock.tunnel

View File

@ -47,6 +47,7 @@
"global.private_key" = "Private key"; "global.private_key" = "Private key";
"global.profile" = "Profile"; "global.profile" = "Profile";
"global.protocol" = "Protocol"; "global.protocol" = "Protocol";
"global.provider" = "Provider";
"global.public_key" = "Public key"; "global.public_key" = "Public key";
"global.purchase" = "Purchase"; "global.purchase" = "Purchase";
"global.remove" = "Delete"; "global.remove" = "Delete";
@ -127,6 +128,8 @@
"views.profile.rows.add_module" = "Add module"; "views.profile.rows.add_module" = "Add module";
"views.profile.module_list.section.footer" = "Drag modules to rearrange them, as their order determines priority."; "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.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.confirm_quit" = "Ask before quit";
"views.settings.rows.lock_in_background" = "Lock in background"; "views.settings.rows.lock_in_background" = "Lock in background";

View File

@ -30,7 +30,7 @@ import UtilsLibrary
extension DNSModule.Builder: ModuleViewProviding { extension DNSModule.Builder: ModuleViewProviding {
func moduleView(with editor: ProfileEditor) -> some View { 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 @Binding
private var draft: DNSModule.Builder private var draft: DNSModule.Builder
init(editor: ProfileEditor, original: DNSModule.Builder) { init(editor: ProfileEditor, module: DNSModule.Builder) {
self.editor = editor self.editor = editor
_draft = editor.binding(forModule: original) _draft = editor.binding(forModule: module)
} }
var body: some View { var body: some View {
@ -62,7 +62,7 @@ private struct DNSView: View {
.labelsHidden() .labelsHidden()
} }
.themeManualInput() .themeManualInput()
.asModuleView(with: editor, draft: draft) .moduleView(editor: editor, draft: draft)
} }
} }

View File

@ -29,7 +29,7 @@ import UtilsLibrary
extension HTTPProxyModule.Builder: ModuleViewProviding { extension HTTPProxyModule.Builder: ModuleViewProviding {
func moduleView(with editor: ProfileEditor) -> some View { 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 @Binding
private var draft: HTTPProxyModule.Builder private var draft: HTTPProxyModule.Builder
init(editor: ProfileEditor, original: HTTPProxyModule.Builder) { init(editor: ProfileEditor, module: HTTPProxyModule.Builder) {
self.editor = editor self.editor = editor
_draft = editor.binding(forModule: original) _draft = editor.binding(forModule: module)
} }
var body: some View { var body: some View {
@ -58,7 +58,7 @@ private struct HTTPProxyView: View {
} }
.labelsHidden() .labelsHidden()
.themeManualInput() .themeManualInput()
.asModuleView(with: editor, draft: draft) .moduleView(editor: editor, draft: draft)
} }
} }

View File

@ -111,7 +111,7 @@ private extension IPView.RouteView {
Button("Add route") { Button("Add route") {
isPresented = true isPresented = true
} }
.sheet(isPresented: $isPresented) { .themeModal(isPresented: $isPresented) {
NavigationStack { NavigationStack {
IPView.RouteView(family: .v4) { IPView.RouteView(family: .v4) {
route = $0 route = $0

View File

@ -29,7 +29,7 @@ import UtilsLibrary
extension IPModule.Builder: ModuleViewProviding { extension IPModule.Builder: ModuleViewProviding {
func moduleView(with editor: ProfileEditor) -> some View { 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 @State
private var routePresentation: RoutePresentation? private var routePresentation: RoutePresentation?
init(editor: ProfileEditor, original: IPModule.Builder) { init(editor: ProfileEditor, module: IPModule.Builder) {
self.editor = editor self.editor = editor
_draft = editor.binding(forModule: original) _draft = editor.binding(forModule: module)
} }
var body: some View { var body: some View {
@ -55,7 +55,7 @@ struct IPView: View {
ipSections(for: .v6) ipSections(for: .v6)
interfaceSection interfaceSection
} }
.asModuleView(with: editor, draft: draft) .moduleView(editor: editor, draft: draft)
.themeModal(item: $routePresentation, content: routeModal) .themeModal(item: $routePresentation, content: routeModal)
} }
} }

View File

@ -29,7 +29,7 @@ import UtilsLibrary
extension OnDemandModule.Builder: ModuleViewProviding { extension OnDemandModule.Builder: ModuleViewProviding {
func moduleView(with editor: ProfileEditor) -> some View { 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( init(
editor: ProfileEditor, editor: ProfileEditor,
original: OnDemandModule.Builder, module: OnDemandModule.Builder,
observer: WifiObserver? = nil observer: WifiObserver? = nil
) { ) {
self.editor = editor self.editor = editor
wifi = Wifi(observer: observer ?? CoreLocationWifiObserver()) wifi = Wifi(observer: observer ?? CoreLocationWifiObserver())
_draft = editor.binding(forModule: original) _draft = editor.binding(forModule: module)
} }
var body: some View { var body: some View {
@ -67,7 +67,7 @@ private struct OnDemandView: View {
enabledSection enabledSection
restrictedArea restrictedArea
} }
.asModuleView(with: editor, draft: draft) .moduleView(editor: editor, draft: draft)
.modifier(PaywallModifier(reason: $paywallReason)) .modifier(PaywallModifier(reason: $paywallReason))
} }
} }
@ -257,7 +257,7 @@ private extension OnDemandView {
return module.preview { return module.preview {
OnDemandView( OnDemandView(
editor: $0, editor: $0,
original: $1, module: $1,
observer: MockWifi() observer: MockWifi()
) )
} }

View File

@ -28,7 +28,7 @@ import SwiftUI
extension OpenVPNModule.Builder: ModuleViewProviding { extension OpenVPNModule.Builder: ModuleViewProviding {
func moduleView(with editor: ProfileEditor) -> some View { 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 { struct OpenVPNView: View {
private enum Subroute: Hashable {
case credentials
}
@ObservedObject @ObservedObject
private var editor: ProfileEditor private var editor: ProfileEditor
@ -57,71 +54,151 @@ struct OpenVPNView: View {
@Binding @Binding
private var draft: OpenVPNModule.Builder private var draft: OpenVPNModule.Builder
@Binding
private var providerId: ProviderID?
@Binding
private var providerEntity: VPNEntity<OpenVPN.Configuration>?
init(serverConfiguration: OpenVPN.Configuration) { init(serverConfiguration: OpenVPN.Configuration) {
let module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder()) let module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder())
editor = ProfileEditor(modules: [module]) let editor = ProfileEditor(modules: [module])
self.editor = editor
_draft = .constant(module) _draft = .constant(module)
_providerId = .constant(nil)
_providerEntity = .constant(nil)
isServerPushed = true isServerPushed = true
} }
init(editor: ProfileEditor, original: OpenVPNModule.Builder) { init(editor: ProfileEditor, module: OpenVPNModule.Builder) {
self.editor = editor 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 isServerPushed = false
} }
var body: some View { var body: some View {
Group { debugChanges()
moduleSection(for: accountRows, header: Strings.Global.account) return contentView
moduleSection(for: remotesRows, header: Strings.Modules.Openvpn.remotes) .modifier(providerModifier)
if !isServerPushed { .moduleView(editor: editor, draft: draft, withName: !isServerPushed)
moduleSection(for: pullRows, header: Strings.Modules.Openvpn.pull) .navigationDestination(for: Subroute.self, destination: destination)
} .themeAnimation(on: providerId, category: .modules)
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
)
}
}
} }
} }
// MARK: - Content
private extension OpenVPNView { private extension OpenVPNView {
var configuration: OpenVPN.Configuration.Builder { var configuration: OpenVPN.Configuration.Builder {
draft.configurationBuilder draft.configurationBuilder
} }
var pullRows: [ModuleRow]? { var providerModifier: some ViewModifier {
configuration.pullMask?.map { ProviderPanelModifier(
.text(caption: $0.localizedDescription, value: nil) providerId: $providerId,
} selectedEntity: $providerEntity,
.nilIfEmpty providerContent: providerContentView
)
} }
@ViewBuilder
var contentView: some View {
credentialsView
if providerId == nil {
manualView
}
}
@ViewBuilder
func providerContentView(providerId: ProviderID, entity: VPNEntity<OpenVPN.Configuration>?) -> 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<OpenVPN.Configuration>) {
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<OpenVPN.Configuration>(
providerId: id,
onSelect: onSelect
)
case .credentials:
CredentialsView(
isInteractive: $draft.isInteractive,
credentials: $draft.credentials
)
}
}
}
// MARK: - Subviews
private extension OpenVPNView {
var accountRows: [ModuleRow]? { var accountRows: [ModuleRow]? {
guard configuration.authUserPass == true else { guard configuration.authUserPass == true || providerId != nil else {
return nil return nil
} }
return [.push(caption: Strings.Modules.Openvpn.credentials, route: HashableRoute(Subroute.credentials))] return [.push(caption: Strings.Modules.Openvpn.credentials, route: HashableRoute(Subroute.credentials))]
@ -136,6 +213,13 @@ private extension OpenVPNView {
.nilIfEmpty .nilIfEmpty
} }
var pullRows: [ModuleRow]? {
configuration.pullMask?.map {
.text(caption: $0.localizedDescription, value: nil)
}
.nilIfEmpty
}
func ipRows(for ip: IPSettings?, routes: [Route]?) -> [ModuleRow]? { func ipRows(for ip: IPSettings?, routes: [Route]?) -> [ModuleRow]? {
var rows: [ModuleRow] = [] var rows: [ModuleRow] = []
if let ip { 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 // MARK: - Previews
// swiftlint: disable force_try // swiftlint: disable force_try

View File

@ -29,11 +29,14 @@ import SwiftUI
extension WireGuardModule.Builder: ModuleViewProviding { extension WireGuardModule.Builder: ModuleViewProviding {
func moduleView(with editor: ProfileEditor) -> some View { func moduleView(with editor: ProfileEditor) -> some View {
WireGuardView(editor: editor, original: self) WireGuardView(editor: editor, module: self)
} }
} }
private struct WireGuardView: View { private struct WireGuardView: View {
private enum Subroute: Hashable {
case providerServer(id: ProviderID)
}
@ObservedObject @ObservedObject
private var editor: ProfileEditor private var editor: ProfileEditor
@ -41,28 +44,64 @@ private struct WireGuardView: View {
@Binding @Binding
private var draft: WireGuardModule.Builder 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 self.editor = editor
_draft = editor.binding(forModule: original) _draft = editor.binding(forModule: module)
// _providerId = editor.binding(forProviderOf: module.id)
} }
var body: some View { var body: some View {
Group { contentView
moduleSection(for: interfaceRows, header: Strings.Modules.Wireguard.interface) // .modifier(providerModifier)
moduleSection(for: dnsRows, header: Strings.Unlocalized.dns) .moduleView(editor: editor, draft: draft)
ForEach(Array(zip(configuration.peers.indices, configuration.peers)), id: \.1.publicKey) { index, peer in // .navigationDestination(for: Subroute.self) {
moduleSection(for: peersRows(for: peer), header: Strings.Modules.Wireguard.peer(index + 1)) // switch $0 {
} // case .providerServer(let id):
} // VPNProviderServerView<WireGuard.Configuration>(providerId: id) {
.asModuleView(with: editor, draft: draft) // providerServer = $1
// }
// }
// }
} }
} }
// MARK: - Content
private extension WireGuardView { private extension WireGuardView {
var configuration: WireGuard.Configuration.Builder { var configuration: WireGuard.Configuration.Builder {
draft.configurationBuilder 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 interfaceRows: [ModuleRow]? {
var rows: [ModuleRow] = [] var rows: [ModuleRow] = []
rows.append(.longContent(caption: Strings.Global.privateKey, value: configuration.interface.privateKey)) rows.append(.longContent(caption: Strings.Global.privateKey, value: configuration.interface.privateKey))

View File

@ -49,7 +49,7 @@ struct ProfileEditView: View, Routable {
debugChanges() debugChanges()
return List { return List {
NameSection( NameSection(
name: $profileEditor.name, name: $profileEditor.profile.name,
placeholder: Strings.Placeholders.Profile.name placeholder: Strings.Placeholders.Profile.name
) )
Group { Group {

View File

@ -64,7 +64,7 @@ struct ModuleListView: View, Routable {
} }
.onDeleteCommand(perform: removeSelectedModule) .onDeleteCommand(perform: removeSelectedModule)
.toolbar(content: toolbarContent) .toolbar(content: toolbarContent)
.navigationTitle(profileEditor.name) .navigationTitle(profileEditor.profile.name)
} }
} }

View File

@ -35,7 +35,7 @@ struct ProfileGeneralView: View {
var body: some View { var body: some View {
Form { Form {
NameSection( NameSection(
name: $profileEditor.name, name: $profileEditor.profile.name,
placeholder: Strings.Placeholders.Profile.name placeholder: Strings.Placeholders.Profile.name
) )
StorageSection( StorageSection(

View File

@ -52,22 +52,3 @@ private extension ProfileEditor {
return (provider, module.typeDescription) return (provider, module.typeDescription)
} }
} }
extension View {
@MainActor
func asModuleView<T>(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)
}
}

View File

@ -70,12 +70,6 @@ struct HashableRoute: Hashable {
} }
} }
extension Collection {
var nilIfEmpty: [Element]? {
!isEmpty ? Array(self) : nil
}
}
extension View { extension View {
func moduleSection(for rows: [ModuleRow]?, header: String) -> some View { func moduleSection(for rows: [ModuleRow]?, header: String) -> some View {
rows.map { rows in rows.map { rows in

View File

@ -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 <http://www.gnu.org/licenses/>.
//
import PassepartoutKit
import SwiftUI
struct ModuleViewModifier<T>: 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<T>(editor: ProfileEditor, draft: T, withName: Bool = true) -> some View where T: ModuleBuilder & Equatable {
modifier(ModuleViewModifier(editor: editor, draft: draft, withName: withName))
}
}

View File

@ -29,9 +29,25 @@ import SwiftUI
extension ProfileEditor { extension ProfileEditor {
func binding(forNameOf moduleId: UUID) -> Binding<String> { func binding(forNameOf moduleId: UUID) -> Binding<String> {
Binding { [weak self] in Binding { [weak self] in
self?.name(forModuleWithId: moduleId) ?? "" self?.profile.name(forModuleWithId: moduleId) ?? ""
} set: { [weak self] in } set: { [weak self] in
self?.setName($0, forModuleWithId: moduleId) self?.profile.setName($0, forModuleWithId: moduleId)
}
}
func binding(forProviderOf moduleId: UUID) -> Binding<ProviderID?> {
Binding { [weak self] in
self?.profile.providerId(forModuleWithId: moduleId)
} set: { [weak self] in
self?.profile.setProviderId($0, forModuleWithId: moduleId)
}
}
func binding<E>(forProviderEntityOf moduleId: UUID) -> Binding<E?> 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)
} }
} }

View File

@ -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 <http://www.gnu.org/licenses/>.
//
import AppLibrary
import PassepartoutKit
import SwiftUI
struct ProviderPanelModifier<Entity, ProviderContent>: 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<OpenVPN.Configuration>?
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()
}

View File

@ -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 <http://www.gnu.org/licenses/>.
//
import PassepartoutKit
import SwiftUI
struct VPNFiltersModifier<Configuration>: 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()
}
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
//
import AppLibrary
import PassepartoutKit
import SwiftUI
// FIXME: #703, providers UI
struct VPNFiltersView<Configuration>: 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<Configuration>] {
manager
.presets(ofType: Configuration.self)
.sorted {
$0.description < $1.description
}
}
}
#Preview {
NavigationStack {
VPNFiltersView<String>(
manager: ProviderFactory.mock.vpnProviderManager,
providerId: .hideme,
onRefresh: {}
)
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
//
import AppLibrary
import PassepartoutKit
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
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)
}
}
// 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<Configuration>? {
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<OpenVPN.Configuration>(apis: [API.bundled], providerId: .protonvpn) { _, _ in
}
}
.withMockEnvironment()
}

View File

@ -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 <http://www.gnu.org/licenses/>.
//
#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<Configuration>(manager: manager, providerId: providerId, onRefresh: onRefresh)
.navigationTitle("Filters")
.navigationBarTitleDisplayMode(.inline)
}
.presentationDetents([.medium])
}
}
}
#endif

View File

@ -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 <http://www.gnu.org/licenses/>.
//
#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

View File

@ -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 <http://www.gnu.org/licenses/>.
//
#if os(macOS)
import SwiftUI
extension VPNFiltersModifier {
func contentView(with content: Content) -> some View {
VStack {
VPNFiltersView<Configuration>(manager: manager, providerId: providerId, onRefresh: onRefresh)
.padding()
content
}
}
}
#endif

View File

@ -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 <http://www.gnu.org/licenses/>.
//
#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

View File

@ -329,13 +329,15 @@ struct ThemeTipModifier: ViewModifier {
// MARK: - Views // MARK: - Views
public enum ThemeAnimationCategory: CaseIterable { public enum ThemeAnimationCategory: CaseIterable {
case diagnostics
case modules
case profiles case profiles
case profilesLayout case profilesLayout
case modules case providers
case diagnostics
} }
struct ThemeImage: View { struct ThemeImage: View {

View File

@ -30,7 +30,7 @@ import SwiftUI
extension Theme { extension Theme {
public convenience init() { public convenience init() {
self.init(dummy: ()) self.init(dummy: ())
animationCategories = [.profiles, .modules, .diagnostics] animationCategories = [.diagnostics, .modules, .profiles, .providers]
} }
} }

View File

@ -32,7 +32,7 @@ extension Theme {
self.init(dummy: Void()) self.init(dummy: Void())
rootModalSize = CGSize(width: 700, height: 400) rootModalSize = CGSize(width: 700, height: 400)
secondaryModalSize = CGSize(width: 500.0, height: 200.0) secondaryModalSize = CGSize(width: 500.0, height: 200.0)
animationCategories = [.profiles, .diagnostics] animationCategories = [.diagnostics, .profiles, .providers]
} }
} }

View File

@ -75,7 +75,7 @@ private extension ConnectionStatusView {
#Preview("Connected") { #Preview("Connected") {
ConnectionStatusView(tunnel: .mock) ConnectionStatusView(tunnel: .mock)
.task { .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) .frame(width: 100, height: 100)
.withMockEnvironment() .withMockEnvironment()
@ -94,7 +94,7 @@ private extension ConnectionStatusView {
} }
return ConnectionStatusView(tunnel: .mock) return ConnectionStatusView(tunnel: .mock)
.task { .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) .frame(width: 100, height: 100)
.withMockEnvironment() .withMockEnvironment()

View File

@ -43,7 +43,7 @@ struct StorageSection: View {
sharingToggle sharingToggle
ThemeCopiableText( ThemeCopiableText(
title: Strings.Unlocalized.uuid, title: Strings.Unlocalized.uuid,
value: profileEditor.id.flatString.localizedDescription(style: .quartets), value: profileEditor.profile.id.flatString.localizedDescription(style: .quartets),
valueView: { valueView: {
Text($0) Text($0)
.monospaced() .monospaced()

View File

@ -30,7 +30,7 @@ import UtilsLibrary
struct TunnelRestartButton<Label>: View where Label: View { struct TunnelRestartButton<Label>: View where Label: View {
@EnvironmentObject @EnvironmentObject
private var iapManager: IAPManager private var profileProcessor: ProfileProcessor
@ObservedObject @ObservedObject
var tunnel: Tunnel var tunnel: Tunnel
@ -55,7 +55,7 @@ struct TunnelRestartButton<Label>: View where Label: View {
pendingTask?.cancel() pendingTask?.cancel()
pendingTask = Task { pendingTask = Task {
do { do {
try await tunnel.connect(with: profile, processor: iapManager) try await tunnel.connect(with: profile, processor: profileProcessor)
} catch { } catch {
errorHandler.handle( errorHandler.handle(
error, error,

View File

@ -43,6 +43,9 @@ struct TunnelToggleButton<Label>: View, TunnelContextProviding, ThemeProviding w
@EnvironmentObject @EnvironmentObject
private var iapManager: IAPManager private var iapManager: IAPManager
@EnvironmentObject
private var profileProcessor: ProfileProcessor
var style: Style = .plain var style: Style = .plain
@ObservedObject @ObservedObject
@ -127,12 +130,12 @@ private extension TunnelToggleButton {
do { do {
if isInstalled { if isInstalled {
if canConnect { if canConnect {
try await tunnel.connect(with: profile, processor: iapManager) try await tunnel.connect(with: profile, processor: profileProcessor)
} else { } else {
try await tunnel.disconnect() try await tunnel.disconnect()
} }
} else { } else {
try await tunnel.connect(with: profile, processor: iapManager) try await tunnel.connect(with: profile, processor: profileProcessor)
} }
} catch { } catch {
errorHandler.handle( errorHandler.handle(

View File

@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>. // along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
// //
import AppLibrary
import SwiftUI import SwiftUI
@MainActor @MainActor
@ -31,6 +32,9 @@ extension View {
environmentObject(theme) environmentObject(theme)
.environmentObject(context.iapManager) .environmentObject(context.iapManager)
.environmentObject(context.connectionObserver) .environmentObject(context.connectionObserver)
.environmentObject(context.providerFactory.providerManager)
.environmentObject(context.providerFactory.vpnProviderManager)
.environmentObject(context.profileProcessor)
} }
public func withMockEnvironment() -> some View { public func withMockEnvironment() -> some View {

View File

@ -38,6 +38,10 @@ public struct Constants: Decodable, Sendable {
public struct Websites: Decodable, Sendable { public struct Websites: Decodable, Sendable {
public let home: URL public let home: URL
public var api: URL {
home.appendingPathComponent("api/")
}
public var faq: URL { public var faq: URL {
home.appendingPathComponent("faq/") home.appendingPathComponent("faq/")
} }

View File

@ -24,6 +24,7 @@
// //
import Foundation import Foundation
import PassepartoutAPIBundle
import PassepartoutKit import PassepartoutKit
import PassepartoutWireGuardGo import PassepartoutWireGuardGo
@ -55,3 +56,38 @@ extension UserDefaults {
return defaults return defaults
}() }()
} }
// TODO: #716, move to Environment
extension API {
public static var shared: [APIMapper] {
#if DEBUG
[API.bundled]
#else
[API.remoteThenBundled]
#endif
}
private static let remoteThenBundled: [APIMapper] = [
Self.remote,
Self.bundled
]
public static let bundled: APIMapper = {
guard let url = API.bundledURL else {
fatalError("Unable to find bundled API")
}
let ws = API.V5.DefaultWebServices(
url,
timeout: Constants.shared.api.timeoutInterval
)
return API.V5.Mapper(webServices: ws)
}()
public static let remote: APIMapper = {
let ws = API.V5.DefaultWebServices(
Constants.shared.websites.api,
timeout: Constants.shared.api.timeoutInterval
)
return API.V5.Mapper(webServices: ws)
}()
}

View File

@ -1,5 +1,5 @@
// //
// Array+Extensions.swift // Collection+Extensions.swift
// Passepartout // Passepartout
// //
// Created by Davide De Rosa on 8/18/24. // Created by Davide De Rosa on 8/18/24.
@ -30,3 +30,9 @@ extension Array where Element == String {
last?.trimmingCharacters(in: .whitespaces) == "" last?.trimmingCharacters(in: .whitespaces) == ""
} }
} }
extension Collection {
public var nilIfEmpty: [Element]? {
!isEmpty ? Array(self) : nil
}
}

View File

@ -0,0 +1,48 @@
//
// String+Extensions.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
extension String {
public var nilIfEmpty: String? {
!isEmpty ? self : nil
}
}
extension String {
public var localizedAsRegionCode: String? {
Locale
.current
.localizedString(forRegionCode: self)?
.capitalized
}
public var localizedAsLanguageCode: String? {
Locale
.current
.localizedString(forLanguageCode: self)?
.capitalized
}
}

View File

@ -137,7 +137,7 @@ private extension GenericCreditsView {
var sortedLanguages: [String] { var sortedLanguages: [String] {
credits.translations.keys.sorted { credits.translations.keys.sorted {
$0.localizedAsCountryCode < $1.localizedAsCountryCode ($0.localizedAsLanguageCode ?? $0) < ($1.localizedAsLanguageCode ?? $1)
} }
} }
@ -178,7 +178,7 @@ private extension GenericCreditsView {
Section { Section {
ForEach(sortedLanguages, id: \.self) { code in ForEach(sortedLanguages, id: \.self) { code in
HStack { HStack {
Text(code.localizedAsCountryCode) Text(code.localizedAsLanguageCode ?? code)
Spacer() Spacer()
credits.translations[code].map { authors in credits.translations[code].map { authors in
VStack(spacing: 4) { VStack(spacing: 4) {
@ -205,12 +205,6 @@ private extension GenericCreditsView {
} }
} }
private extension String {
var localizedAsCountryCode: String {
Locale.current.localizedString(forLanguageCode: self)?.capitalized ?? self
}
}
// MARK: - // MARK: -
@MainActor @MainActor

View File

@ -44,7 +44,7 @@ extension ProfileEditorTests {
DNSModule.Builder(), DNSModule.Builder(),
IPModule.Builder() IPModule.Builder()
]) ])
XCTAssertTrue(sut.name.isEmpty) XCTAssertTrue(sut.profile.name.isEmpty)
XCTAssertTrue(sut.modules[0] is DNSModule.Builder) XCTAssertTrue(sut.modules[0] is DNSModule.Builder)
XCTAssertTrue(sut.modules[1] is IPModule.Builder) XCTAssertTrue(sut.modules[1] is IPModule.Builder)
} }
@ -60,7 +60,7 @@ extension ProfileEditorTests {
).tryBuild() ).tryBuild()
let sut = ProfileEditor(profile: profile) let sut = ProfileEditor(profile: profile)
XCTAssertEqual(sut.name, name) XCTAssertEqual(sut.profile.name, name)
XCTAssertTrue(sut.modules[0] is DNSModule.Builder) XCTAssertTrue(sut.modules[0] is DNSModule.Builder)
XCTAssertTrue(sut.modules[1] is IPModule.Builder) XCTAssertTrue(sut.modules[1] is IPModule.Builder)
XCTAssertEqual(sut.activeModulesIds, [dns.id]) XCTAssertEqual(sut.activeModulesIds, [dns.id])
@ -193,7 +193,7 @@ extension ProfileEditorTests {
func test_givenProfile_whenBuild_thenSucceeds() throws { func test_givenProfile_whenBuild_thenSucceeds() throws {
let wg = WireGuardModule.Builder(configurationBuilder: .default) let wg = WireGuardModule.Builder(configurationBuilder: .default)
let sut = ProfileEditor(modules: [wg]) let sut = ProfileEditor(modules: [wg])
sut.name = "hello" sut.profile.name = "hello"
let profile = try sut.build() let profile = try sut.build()
XCTAssertEqual(profile.name, "hello") XCTAssertEqual(profile.name, "hello")

View File

@ -27,6 +27,7 @@ import AppData
import AppDataProfiles import AppDataProfiles
import AppLibrary import AppLibrary
import AppUI import AppUI
import CommonLibrary
import Foundation import Foundation
import PassepartoutKit import PassepartoutKit
import UtilsLibrary import UtilsLibrary
@ -35,9 +36,11 @@ extension AppContext {
static let shared = AppContext( static let shared = AppContext(
iapManager: .shared, iapManager: .shared,
profileManager: .shared, profileManager: .shared,
profileProcessor: .shared,
tunnel: .shared, tunnel: .shared,
tunnelEnvironment: .shared, tunnelEnvironment: .shared,
registry: .shared, registry: .shared,
providerFactory: .shared,
constants: .shared constants: .shared
) )
} }
@ -114,6 +117,34 @@ extension IAPManager {
} }
} }
extension ProfileProcessor {
static let shared = ProfileProcessor { profile in
var builder = profile.builder()
// suppress on-demand rules if not eligible
if !IAPManager.shared.isEligible(for: .onDemand) {
pp_log(.app, .notice, "Suppress on-demand rules, not eligible")
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()
}
}
let processed = try builder.tryBuild()
do {
return try processed.withProviderModules()
} catch {
// FIXME: #703, alert unable to build provider server
pp_log(.app, .error, "Unable to inject provider modules: \(error)")
return processed
}
}
}
// MARK: - // MARK: -
#if targetEnvironment(simulator) #if targetEnvironment(simulator)
@ -167,6 +198,16 @@ private var neRepository: NETunnelManagerRepository {
// MARK: - // MARK: -
// FIXME: #705, store providers to Core Data
extension ProviderFactory {
static let shared = ProviderFactory(
providerManager: ProviderManager(repository: InMemoryProviderRepository()),
vpnProviderManager: VPNProviderManager(repository: InMemoryVPNProviderRepository())
)
}
// MARK: -
extension CoreDataPersistentStoreLogger where Self == DefaultCoreDataPersistentStoreLogger { extension CoreDataPersistentStoreLogger where Self == DefaultCoreDataPersistentStoreLogger {
static var `default`: CoreDataPersistentStoreLogger { static var `default`: CoreDataPersistentStoreLogger {
DefaultCoreDataPersistentStoreLogger() DefaultCoreDataPersistentStoreLogger()