diff --git a/Library/Sources/CommonUtils/Extensions/UUID+RawRepresentable.swift b/Library/Sources/AppDataPreferences/Domain/CDExcludedEndpoint.swift similarity index 63% rename from Library/Sources/CommonUtils/Extensions/UUID+RawRepresentable.swift rename to Library/Sources/AppDataPreferences/Domain/CDExcludedEndpoint.swift index 2c1c2fe0..adb04bb2 100644 --- a/Library/Sources/CommonUtils/Extensions/UUID+RawRepresentable.swift +++ b/Library/Sources/AppDataPreferences/Domain/CDExcludedEndpoint.swift @@ -1,8 +1,8 @@ // -// UUID+RawRepresentable.swift +// CDExcludedEndpoint.swift // Passepartout // -// Created by Davide De Rosa on 12/4/24. +// Created by Davide De Rosa on 12/8/24. // Copyright (c) 2024 Davide De Rosa. All rights reserved. // // https://github.com/passepartoutvpn @@ -23,14 +23,16 @@ // along with Passepartout. If not, see . // +import CoreData import Foundation -extension UUID: @retroactive RawRepresentable { - public init?(rawValue: String) { - self.init(uuidString: rawValue) +@objc(CDExcludedEndpoint) +final class CDExcludedEndpoint: NSManagedObject { + @nonobjc static func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "CDExcludedEndpoint") } - public var rawValue: String { - uuidString - } + @NSManaged var endpoint: String? + @NSManaged var modulePreferences: CDModulePreferencesV3? + @NSManaged var providerPreferences: CDProviderPreferencesV3? } diff --git a/Library/Sources/AppDataPreferences/Domain/CDModulePreferencesV3.swift b/Library/Sources/AppDataPreferences/Domain/CDModulePreferencesV3.swift index 18d43570..3b5de56b 100644 --- a/Library/Sources/AppDataPreferences/Domain/CDModulePreferencesV3.swift +++ b/Library/Sources/AppDataPreferences/Domain/CDModulePreferencesV3.swift @@ -33,4 +33,5 @@ final class CDModulePreferencesV3: NSManagedObject { } @NSManaged var uuid: UUID? + @NSManaged var excludedEndpoints: Set? } diff --git a/Library/Sources/AppDataPreferences/Domain/CDProviderPreferencesV3.swift b/Library/Sources/AppDataPreferences/Domain/CDProviderPreferencesV3.swift index 2062996a..c9d44b7b 100644 --- a/Library/Sources/AppDataPreferences/Domain/CDProviderPreferencesV3.swift +++ b/Library/Sources/AppDataPreferences/Domain/CDProviderPreferencesV3.swift @@ -34,4 +34,5 @@ final class CDProviderPreferencesV3: NSManagedObject { @NSManaged var providerId: String? @NSManaged var favoriteServerIds: Data? + @NSManaged var excludedEndpoints: Set? } diff --git a/Library/Sources/AppDataPreferences/Domain/Mapper.swift b/Library/Sources/AppDataPreferences/Domain/Mapper.swift index ccbb0979..4f37d23e 100644 --- a/Library/Sources/AppDataPreferences/Domain/Mapper.swift +++ b/Library/Sources/AppDataPreferences/Domain/Mapper.swift @@ -29,12 +29,27 @@ import Foundation import PassepartoutKit struct DomainMapper { - func preferences(from entity: CDModulePreferencesV3) throws -> ModulePreferences { - ModulePreferences() + func excludedEndpoints(from entities: Set?) -> Set { + entities.map { + Set($0.compactMap { + $0.endpoint.map { + ExtendedEndpoint(rawValue: $0) + } ?? nil + }) + } ?? [] } } struct CoreDataMapper { - func set(_ entity: CDModulePreferencesV3, from preferences: ModulePreferences) throws { + let context: NSManagedObjectContext + + func cdExcludedEndpoint(from endpoint: ExtendedEndpoint) -> CDExcludedEndpoint { + let cdEndpoint = CDExcludedEndpoint(context: context) + cdEndpoint.endpoint = endpoint.rawValue + return cdEndpoint + } + + func cdExcludedEndpoints(from endpoints: Set) -> Set { + Set(endpoints.map(cdExcludedEndpoint(from:))) } } diff --git a/Library/Sources/AppDataPreferences/Preferences.xcdatamodeld/Preferences.xcdatamodel/contents b/Library/Sources/AppDataPreferences/Preferences.xcdatamodeld/Preferences.xcdatamodel/contents index 4ad3589c..f482947b 100644 --- a/Library/Sources/AppDataPreferences/Preferences.xcdatamodeld/Preferences.xcdatamodel/contents +++ b/Library/Sources/AppDataPreferences/Preferences.xcdatamodeld/Preferences.xcdatamodel/contents @@ -1,10 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift b/Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift index d279797f..f14a681d 100644 --- a/Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift +++ b/Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift @@ -30,74 +30,71 @@ import Foundation import PassepartoutKit extension AppData { - public static func cdModulePreferencesRepositoryV3(context: NSManagedObjectContext) -> ModulePreferencesRepository { - CDModulePreferencesRepositoryV3(context: context) + public static func cdModulePreferencesRepositoryV3(context: NSManagedObjectContext, moduleId: UUID) throws -> ModulePreferencesRepository { + try CDModulePreferencesRepositoryV3(context: context, moduleId: moduleId) } } private final class CDModulePreferencesRepositoryV3: ModulePreferencesRepository { private nonisolated let context: NSManagedObjectContext - init(context: NSManagedObjectContext) { + private let entity: CDModulePreferencesV3 + + init(context: NSManagedObjectContext, moduleId: UUID) throws { self.context = context - } - func preferences(for moduleIds: [UUID]) throws -> [UUID: ModulePreferences] { - try context.performAndWait { + entity = try context.performAndWait { let request = CDModulePreferencesV3.fetchRequest() - request.predicate = NSPredicate(format: "any uuid in %@", moduleIds.map(\.uuidString)) - - let entities = try request.execute() - let mapper = DomainMapper() - return entities.reduce(into: [:]) { - guard let moduleId = $1.uuid else { - return - } - do { - let preferences = try mapper.preferences(from: $1) - $0[moduleId] = preferences - } catch { - pp_log(.app, .error, "Unable to load preferences for module \(moduleId): \(error)") - } - } - } - } - - func set(_ preferences: [UUID: ModulePreferences]) throws { - try context.performAndWait { - let request = CDModulePreferencesV3.fetchRequest() - request.predicate = NSPredicate(format: "any uuid in %@", Array(preferences.keys)) - - var entities = try request.execute() - let existingIds = entities.compactMap(\.uuid) - let newIds = Set(preferences.keys).subtracting(existingIds) - newIds.forEach { - let newEntity = CDModulePreferencesV3(context: context) - newEntity.uuid = $0 - entities.append(newEntity) - } - - let mapper = CoreDataMapper() - try entities.forEach { - guard let id = $0.uuid, let entityPreferences = preferences[id] else { - return - } - try mapper.set($0, from: entityPreferences) - } - - guard context.hasChanges else { - return - } + request.predicate = NSPredicate(format: "uuid == %@", moduleId.uuidString) do { - try context.save() + let entity = try request.execute().first ?? CDModulePreferencesV3(context: context) + entity.uuid = moduleId + return entity } catch { - context.rollback() + pp_log(.app, .error, "Unable to load preferences for module \(moduleId): \(error)") throw error } } } - func rollback() { - context.rollback() + func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool { + context.performAndWait { + entity.excludedEndpoints?.contains { + $0.endpoint == endpoint.rawValue + } ?? false + } + } + + func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) { + context.performAndWait { + let mapper = CoreDataMapper(context: context) + let cdEndpoint = mapper.cdExcludedEndpoint(from: endpoint) + cdEndpoint.modulePreferences = entity + entity.excludedEndpoints?.insert(cdEndpoint) + } + } + + func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) { + context.performAndWait { + guard let found = entity.excludedEndpoints?.first(where: { + $0.endpoint == endpoint.rawValue + }) else { + return + } + entity.excludedEndpoints?.remove(found) + context.delete(found) + } + } + + func save() throws { + guard context.hasChanges else { + return + } + do { + try context.save() + } catch { + context.rollback() + throw error + } } } diff --git a/Library/Sources/AppDataPreferences/Strategy/CDProviderPreferencesRepositoryV3.swift b/Library/Sources/AppDataPreferences/Strategy/CDProviderPreferencesRepositoryV3.swift index fd86e42e..992d53b4 100644 --- a/Library/Sources/AppDataPreferences/Strategy/CDProviderPreferencesRepositoryV3.swift +++ b/Library/Sources/AppDataPreferences/Strategy/CDProviderPreferencesRepositoryV3.swift @@ -82,6 +82,35 @@ private final class CDProviderPreferencesRepositoryV3: ProviderPreferencesReposi } } + func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool { + context.performAndWait { + entity.excludedEndpoints?.contains { + $0.endpoint == endpoint.rawValue + } ?? false + } + } + + func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) { + context.performAndWait { + let mapper = CoreDataMapper(context: context) + let cdEndpoint = mapper.cdExcludedEndpoint(from: endpoint) + cdEndpoint.providerPreferences = entity + entity.excludedEndpoints?.insert(cdEndpoint) + } + } + + func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) { + context.performAndWait { + guard let found = entity.excludedEndpoints?.first(where: { + $0.endpoint == endpoint.rawValue + }) else { + return + } + entity.excludedEndpoints?.remove(found) + context.delete(found) + } + } + func save() throws { guard context.hasChanges else { return diff --git a/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift b/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift index 781f5cc3..6e153350 100644 --- a/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift +++ b/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift @@ -229,11 +229,7 @@ extension AppCoordinator { extension AppCoordinator { public func onInteractiveLogin(_ profile: Profile, _ onComplete: @escaping InteractiveManager.CompletionBlock) { pp_log(.app, .info, "Present interactive login") - interactiveManager.present( - with: profile, - preferencesManager: preferencesManager, - onComplete: onComplete - ) + interactiveManager.present(with: profile, onComplete: onComplete) } public func onProviderEntityRequired(_ profile: Profile, force: Bool) { @@ -302,7 +298,7 @@ private extension AppCoordinator { func editProfile(_ profile: EditableProfile, initialModuleId: UUID?) { profilePath = NavigationPath() let isShared = profileManager.isRemotelyShared(profileWithId: profile.id) - profileEditor.load(profile, isShared: isShared, preferencesManager: preferencesManager) + profileEditor.load(profile, isShared: isShared) present(.editProfile(initialModuleId)) } } diff --git a/Library/Sources/AppUIMain/Views/Modules/OpenVPNView+Configuration.swift b/Library/Sources/AppUIMain/Views/Modules/OpenVPNView+Configuration.swift index 6cd7dc90..d18f08cb 100644 --- a/Library/Sources/AppUIMain/Views/Modules/OpenVPNView+Configuration.swift +++ b/Library/Sources/AppUIMain/Views/Modules/OpenVPNView+Configuration.swift @@ -23,6 +23,8 @@ // along with Passepartout. If not, see . // +import CommonLibrary +import CommonUtils import PassepartoutKit import SwiftUI @@ -34,9 +36,12 @@ extension OpenVPNView { let credentialsRoute: (any Hashable)? + @ObservedObject + var allowedEndpoints: Blacklist + var body: some View { moduleSection(for: accountRows, header: Strings.Global.Nouns.account) - moduleSection(for: remotesRows, header: Strings.Modules.Openvpn.remotes) + remotesSection if !isServerPushed { moduleSection(for: pullRows, header: Strings.Modules.Openvpn.pull) } @@ -61,6 +66,68 @@ extension OpenVPNView { } } +// MARK: - Editable + +private extension OpenVPNView.ConfigurationView { + var remotesSection: some View { + configuration.remotes.map { remotes in + ForEach(remotes, id: \.rawValue) { remote in + SelectableRemoteButton( + remote: remote, + all: Set(remotes), + allowedEndpoints: allowedEndpoints + ) + } + .themeSection(header: Strings.Modules.Openvpn.remotes) + } + } +} + +private struct SelectableRemoteButton: View { + let remote: ExtendedEndpoint + + let all: Set + + @ObservedObject + var allowedEndpoints: Blacklist + + var body: some View { + Button { + if allowedEndpoints.isAllowed(remote) { + if remaining.count > 1 { + allowedEndpoints.deny(remote) + } + } else { + allowedEndpoints.allow(remote) + } + } label: { + HStack { + VStack(alignment: .leading) { + Text(remote.address.rawValue) + .font(.headline) + + Text("\(remote.proto.socketType.rawValue):\(remote.proto.port.description)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + ThemeImage(.marked) + .opaque(allowedEndpoints.isAllowed(remote)) + } + .contentShape(.rect) + } + .buttonStyle(.plain) + } + + private var remaining: Set { + all.filter { + allowedEndpoints.isAllowed($0) + } + } +} + +// MARK: - Constant + private extension OpenVPNView.ConfigurationView { var accountRows: [ModuleRow]? { guard let credentialsRoute else { @@ -75,20 +142,14 @@ private extension OpenVPNView.ConfigurationView { ] } - var remotesRows: [ModuleRow]? { - configuration.remotes?.map { - .copiableText( - value: "\($0.address.rawValue) → \($0.proto.socketType.rawValue):\($0.proto.port)" - ) - } - .nilIfEmpty - } - var pullRows: [ModuleRow]? { - configuration.pullMask?.map { - .text(caption: $0.localizedDescription, value: nil) - } - .nilIfEmpty + configuration.pullMask? + .map(\.localizedDescription) + .sorted() + .map { + .text(caption: $0, value: nil) + } + .nilIfEmpty } func ipRows(for ip: IPSettings?, routes: [Route]?) -> [ModuleRow]? { @@ -130,16 +191,15 @@ private extension OpenVPNView.ConfigurationView { configuration.routingPolicies? .compactMap { switch $0 { - case .IPv4: - return .text(caption: Strings.Unlocalized.ipv4) - - case .IPv6: - return .text(caption: Strings.Unlocalized.ipv6) - - default: - return nil + case .IPv4: return Strings.Unlocalized.ipv4 + case .IPv6: return Strings.Unlocalized.ipv6 + default: return nil } } + .sorted() + .map { + .text(caption: $0) + } .nilIfEmpty } @@ -273,3 +333,33 @@ private extension OpenVPNView.ConfigurationView { return rows.nilIfEmpty } } + +// MARK: - Previews + +#Preview { + struct Preview: View { + + @StateObject + private var allowedEndpoints = Blacklist { _ in + true + } allow: { _ in + // + } deny: { _ in + // + } + + var body: some View { + Form { + OpenVPNView.ConfigurationView( + isServerPushed: false, + configuration: .forPreviews, + credentialsRoute: nil, + allowedEndpoints: allowedEndpoints + ) + } + .withMockEnvironment() + } + } + + return Preview() +} diff --git a/Library/Sources/AppUIMain/Views/Modules/OpenVPNView+Extensions.swift b/Library/Sources/AppUIMain/Views/Modules/OpenVPNView+Extensions.swift new file mode 100644 index 00000000..7233cd45 --- /dev/null +++ b/Library/Sources/AppUIMain/Views/Modules/OpenVPNView+Extensions.swift @@ -0,0 +1,89 @@ +// +// OpenVPNView+Extensions.swift +// Passepartout +// +// Created by Davide De Rosa on 12/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 . +// + +import Foundation +import PassepartoutKit + +// swiftlint: disable force_try +extension OpenVPN.Configuration.Builder { + static var forPreviews: Self { + var builder = OpenVPN.Configuration.Builder(withFallbacks: true) + builder.noPullMask = [.proxy] + builder.authUserPass = true + builder.remotes = [ + .init(rawValue: "2.2.2.2:UDP:2222")!, + .init(rawValue: "6.6.6.6:UDP:6666")!, + .init(rawValue: "12.12.12.12:TCP:21212")!, + .init(rawValue: "12:12:12:12:20:20:20:20:TCP6:21212")! + ] + builder.ipv4 = IPSettings(subnet: try! .init("5.5.5.5", 24)) + .including(routes: [ + .init(defaultWithGateway: .ip("120.1.1.1", .v4)), + .init(.init(rawValue: "55.10.20.30/32"), nil) + ]) + .excluding(routes: [ + .init(.init(rawValue: "88.40.30.30/32"), nil), + .init(.init(rawValue: "60.60.60.60/32"), .ip("127.0.0.1", .v4)) + ]) + builder.ipv6 = IPSettings(subnet: try! .init("::5", 24)) + .including(routes: [ + .init(defaultWithGateway: .ip("120::1:1:1", .v6)), + .init(.init(rawValue: "55:10:20::30/128"), nil), + .init(.init(rawValue: "60:60:60::60/128"), .ip("::2", .v6)) + ]) + .excluding(routes: [ + .init(.init(rawValue: "88:40:30::30/32"), nil) + ]) + builder.routingPolicies = [.IPv4, .IPv6] + builder.dnsServers = ["1.2.3.4", "4.5.6.7"] + builder.dnsDomain = "domain.com" + builder.searchDomains = ["search1.com", "search2.com"] + builder.httpProxy = try! .init("10.10.10.10", 1080) + builder.httpsProxy = try! .init("10.10.10.10", 8080) + builder.proxyAutoConfigurationURL = URL(string: "https://hello.pac")! + builder.proxyBypassDomains = ["bypass1.com", "bypass2.com"] + builder.xorMethod = .xormask(mask: .init(Data(hex: "1234"))) + builder.ca = .init(mockPem: "ca-certificate") + builder.clientCertificate = .init(mockPem: "client-certificate") + builder.clientKey = .init(mockPem: "client-key") + builder.tlsWrap = .init(strategy: .auth, key: .init(biData: Data(count: 256))) + builder.keepAliveInterval = 10.0 + builder.renegotiatesAfter = 60.0 + builder.randomizeEndpoint = true + builder.randomizeHostnames = true + return builder + } +} +// swiftlint: enable force_try + +private extension OpenVPN.CryptoContainer { + init(mockPem: String) { + self.init(pem: """ +-----BEGIN CERTIFICATE----- +\(mockPem) +-----END CERTIFICATE----- +""") + } +} diff --git a/Library/Sources/AppUIMain/Views/Modules/OpenVPNView+Import.swift b/Library/Sources/AppUIMain/Views/Modules/OpenVPNView+Import.swift new file mode 100644 index 00000000..61e104ea --- /dev/null +++ b/Library/Sources/AppUIMain/Views/Modules/OpenVPNView+Import.swift @@ -0,0 +1,122 @@ +// +// OpenVPNView+Import.swift +// Passepartout +// +// Created by Davide De Rosa on 12/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 . +// + +import CommonLibrary +import CommonUtils +import PassepartoutKit +import SwiftUI + +extension OpenVPNView { + struct ImportModifier: ViewModifier { + + @Binding + var draft: OpenVPNModule.Builder + + let impl: OpenVPNModule.Implementation? + + @Binding + var isImporting: Bool + + @ObservedObject + var errorHandler: ErrorHandler + + @State + private var importURL: URL? + + @State + private var importPassphrase: String? + + @State + private var requiresPassphrase = false + + func body(content: Content) -> some View { + content + .fileImporter( + isPresented: $isImporting, + allowedContentTypes: [.item], + onCompletion: importConfiguration + ) + .alert( + draft.moduleType.localizedDescription, + isPresented: $requiresPassphrase, + presenting: importURL, + actions: { url in + SecureField( + Strings.Placeholders.secret, + text: $importPassphrase ?? "" + ) + Button(Strings.Alerts.Import.Passphrase.ok) { + importConfiguration(from: .success(url)) + } + Button(Strings.Global.Actions.cancel, role: .cancel) { + isImporting = false + } + }, + message: { + Text(Strings.Alerts.Import.Passphrase.message($0.lastPathComponent)) + } + ) + } + } +} + +private extension OpenVPNView.ImportModifier { + func importConfiguration(from result: Result) { + do { + let url = try result.get() + guard url.startAccessingSecurityScopedResource() else { + throw AppError.permissionDenied + } + defer { + url.stopAccessingSecurityScopedResource() + } + importURL = url + + guard let impl else { + fatalError("Requires OpenVPNModule implementation") + } + guard let parser = impl.importer as? StandardOpenVPNParser else { + fatalError("OpenVPNModule importer should be StandardOpenVPNParser") + } + let parsed = try parser.parsed(fromURL: url, passphrase: importPassphrase) + + draft.configurationBuilder = parsed.configuration.builder() + } catch StandardOpenVPNParserError.encryptionPassphrase, + StandardOpenVPNParserError.unableToDecrypt { + Task { + // XXX: re-present same alert after artificial delay + try? await Task.sleep(for: .milliseconds(500)) + importPassphrase = nil + requiresPassphrase = true + } + } catch { + pp_log(.app, .error, "Unable to import OpenVPN configuration: \(error)") + errorHandler.handle( + (error as? StandardOpenVPNParserError)?.asPassepartoutError ?? error, + title: draft.moduleType.localizedDescription + ) + } + } +} diff --git a/Library/Sources/AppUIMain/Views/Modules/OpenVPNView.swift b/Library/Sources/AppUIMain/Views/Modules/OpenVPNView.swift index b2aa002f..fd67724d 100644 --- a/Library/Sources/AppUIMain/Views/Modules/OpenVPNView.swift +++ b/Library/Sources/AppUIMain/Views/Modules/OpenVPNView.swift @@ -30,6 +30,9 @@ import SwiftUI struct OpenVPNView: View, ModuleDraftEditing { + @EnvironmentObject + private var preferencesManager: PreferencesManager + @Environment(\.navigationPath) private var path @@ -45,18 +48,12 @@ struct OpenVPNView: View, ModuleDraftEditing { @State private var isImporting = false - @State - private var importURL: URL? - - @State - private var importPassphrase: String? - - @State - private var requiresPassphrase = false - @State private var paywallReason: PaywallReason? + @StateObject + private var providerPreferences = ProviderPreferences() + @StateObject private var errorHandler: ErrorHandler = .default() @@ -64,7 +61,6 @@ struct OpenVPNView: View, ModuleDraftEditing { module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder()) editor = ProfileEditor(modules: [module]) assert(module.configurationBuilder != nil, "isServerPushed must imply module.configurationBuilder != nil") - impl = nil isServerPushed = true } @@ -79,11 +75,12 @@ struct OpenVPNView: View, ModuleDraftEditing { var body: some View { contentView .moduleView(editor: editor, draft: draft.wrappedValue) - .fileImporter( - isPresented: $isImporting, - allowedContentTypes: [.item], - onCompletion: importConfiguration - ) + .modifier(ImportModifier( + draft: draft, + impl: impl, + isImporting: $isImporting, + errorHandler: errorHandler + )) .modifier(PaywallModifier(reason: $paywallReason)) .navigationDestination(for: Subroute.self, destination: destination) .themeAnimation(on: draft.wrappedValue.providerId, category: .modules) @@ -101,7 +98,8 @@ private extension OpenVPNView { ConfigurationView( isServerPushed: isServerPushed, configuration: configuration, - credentialsRoute: Subroute.credentials + credentialsRoute: Subroute.credentials, + allowedEndpoints: allowedEndpoints ) } else { emptyConfigurationView @@ -126,31 +124,12 @@ private extension OpenVPNView { Button(Strings.Modules.General.Rows.importFromFile.withTrailingDots) { isImporting = true } - .alert( - module.moduleType.localizedDescription, - isPresented: $requiresPassphrase, - presenting: importURL, - actions: { url in - SecureField( - Strings.Placeholders.secret, - text: $importPassphrase ?? "" - ) - Button(Strings.Alerts.Import.Passphrase.ok) { - importConfiguration(from: .success(url)) - } - Button(Strings.Global.Actions.cancel, role: .cancel) { - isImporting = false - } - }, - message: { - Text(Strings.Alerts.Import.Passphrase.message($0.lastPathComponent)) - } - ) } var providerModifier: some ViewModifier { VPNProviderContentModifier( providerId: providerId, + providerPreferences: providerPreferences, selectedEntity: providerEntity, paywallReason: $paywallReason, entityDestination: Subroute.providerServer, @@ -165,50 +144,6 @@ private extension OpenVPNView { } } -private extension OpenVPNView { - func onSelectServer(server: VPNServer, preset: VPNPreset) { - draft.wrappedValue.providerEntity = VPNEntity(server: server, preset: preset) - path.wrappedValue.removeLast() - } - - func importConfiguration(from result: Result) { - do { - let url = try result.get() - guard url.startAccessingSecurityScopedResource() else { - throw AppError.permissionDenied - } - defer { - url.stopAccessingSecurityScopedResource() - } - importURL = url - - guard let impl else { - fatalError("Requires OpenVPNModule implementation") - } - guard let parser = impl.importer as? StandardOpenVPNParser else { - fatalError("OpenVPNModule importer should be StandardOpenVPNParser") - } - let parsed = try parser.parsed(fromURL: url, passphrase: importPassphrase) - - draft.wrappedValue.configurationBuilder = parsed.configuration.builder() - } catch StandardOpenVPNParserError.encryptionPassphrase, - StandardOpenVPNParserError.unableToDecrypt { - Task { - // XXX: re-present same alert after artificial delay - try? await Task.sleep(for: .milliseconds(500)) - importPassphrase = nil - requiresPassphrase = true - } - } catch { - pp_log(.app, .error, "Unable to import OpenVPN configuration: \(error)") - errorHandler.handle( - (error as? StandardOpenVPNParserError)?.asPassepartoutError ?? error, - title: module.moduleType.localizedDescription - ) - } - } -} - // MARK: - Destinations private extension OpenVPNView { @@ -239,7 +174,8 @@ private extension OpenVPNView { ConfigurationView( isServerPushed: false, configuration: configuration.builder(), - credentialsRoute: nil + credentialsRoute: nil, + allowedEndpoints: allowedEndpoints ) } .themeForm() @@ -260,66 +196,30 @@ private extension OpenVPNView { } } -// MARK: - Previews +// MARK: - Logic -// swiftlint: disable force_try -#Preview { - var builder = OpenVPN.Configuration.Builder(withFallbacks: true) - builder.noPullMask = [.proxy] - builder.authUserPass = true - builder.remotes = [ - .init(rawValue: "2.2.2.2:UDP:2222")!, - .init(rawValue: "6.6.6.6:UDP:6666")!, - .init(rawValue: "12.12.12.12:TCP:21212")!, - .init(rawValue: "12:12:12:12:20:20:20:20:TCP6:21212")! - ] - builder.ipv4 = IPSettings(subnet: try! .init("5.5.5.5", 24)) - .including(routes: [ - .init(defaultWithGateway: .ip("120.1.1.1", .v4)), - .init(.init(rawValue: "55.10.20.30/32"), nil) - ]) - .excluding(routes: [ - .init(.init(rawValue: "88.40.30.30/32"), nil), - .init(.init(rawValue: "60.60.60.60/32"), .ip("127.0.0.1", .v4)) - ]) - builder.ipv6 = IPSettings(subnet: try! .init("::5", 24)) - .including(routes: [ - .init(defaultWithGateway: .ip("120::1:1:1", .v6)), - .init(.init(rawValue: "55:10:20::30/128"), nil), - .init(.init(rawValue: "60:60:60::60/128"), .ip("::2", .v6)) - ]) - .excluding(routes: [ - .init(.init(rawValue: "88:40:30::30/32"), nil) - ]) - builder.routingPolicies = [.IPv4, .IPv6] - builder.dnsServers = ["1.2.3.4", "4.5.6.7"] - builder.dnsDomain = "domain.com" - builder.searchDomains = ["search1.com", "search2.com"] - builder.httpProxy = try! .init("10.10.10.10", 1080) - builder.httpsProxy = try! .init("10.10.10.10", 8080) - builder.proxyAutoConfigurationURL = URL(string: "https://hello.pac")! - builder.proxyBypassDomains = ["bypass1.com", "bypass2.com"] - builder.xorMethod = .xormask(mask: .init(Data(hex: "1234"))) - builder.ca = .init(mockPem: "ca-certificate") - builder.clientCertificate = .init(mockPem: "client-certificate") - builder.clientKey = .init(mockPem: "client-key") - builder.tlsWrap = .init(strategy: .auth, key: .init(biData: Data(count: 256))) - builder.keepAliveInterval = 10.0 - builder.renegotiatesAfter = 60.0 - builder.randomizeEndpoint = true - builder.randomizeHostnames = true +private extension OpenVPNView { + var preferences: ModulePreferences { + editor.preferences(forModuleWithId: module.id, manager: preferencesManager) + } - let module = OpenVPNModule.Builder(configurationBuilder: builder) - return module.preview(title: "OpenVPN") -} -// swiftlint: enable force_try + var allowedEndpoints: Blacklist { + if draft.wrappedValue.providerSelection != nil { + return providerPreferences.allowedEndpoints() + } else { + return preferences.allowedEndpoints() + } + } -private extension OpenVPN.CryptoContainer { - init(mockPem: String) { - self.init(pem: """ ------BEGIN CERTIFICATE----- -\(mockPem) ------END CERTIFICATE----- -""") + func onSelectServer(server: VPNServer, preset: VPNPreset) { + draft.wrappedValue.providerEntity = VPNEntity(server: server, preset: preset) + path.wrappedValue.removeLast() } } + +// MARK: - Previews + +#Preview { + let module = OpenVPNModule.Builder(configurationBuilder: .forPreviews) + return module.preview(title: "OpenVPN") +} diff --git a/Library/Sources/AppUIMain/Views/Modules/WireGuardView.swift b/Library/Sources/AppUIMain/Views/Modules/WireGuardView.swift index 5ac8bdc5..4cd7a012 100644 --- a/Library/Sources/AppUIMain/Views/Modules/WireGuardView.swift +++ b/Library/Sources/AppUIMain/Views/Modules/WireGuardView.swift @@ -79,6 +79,7 @@ private extension WireGuardView { var providerModifier: some ViewModifier { VPNProviderContentModifier( providerId: providerId, + providerPreferences: nil, selectedEntity: providerEntity, paywallReason: $paywallReason, entityDestination: Subroute.providerServer, diff --git a/Library/Sources/AppUIMain/Views/Providers/ProviderContentModifier.swift b/Library/Sources/AppUIMain/Views/Providers/ProviderContentModifier.swift index 321b3aed..c45407c9 100644 --- a/Library/Sources/AppUIMain/Views/Providers/ProviderContentModifier.swift +++ b/Library/Sources/AppUIMain/Views/Providers/ProviderContentModifier.swift @@ -34,11 +34,16 @@ struct ProviderContentModifier: ViewModifier where Entity: @EnvironmentObject private var providerManager: ProviderManager + @EnvironmentObject + private var preferencesManager: PreferencesManager + let apis: [APIMapper] @Binding var providerId: ProviderID? + let providerPreferences: ProviderPreferences? + let entityType: Entity.Type @Binding @@ -57,9 +62,11 @@ struct ProviderContentModifier: ViewModifier where Entity: if let newId { await refreshInfrastructure(for: newId) } + loadPreferences(for: newId) onSelectProvider(providerManager, newId, false) } } + .onDisappear(perform: savePreferences) .disabled(providerManager.isLoading) content @@ -147,6 +154,7 @@ private extension ProviderContentModifier { await refreshIndex() if let providerId { onSelectProvider(providerManager, providerId, true) + loadPreferences(for: providerId) } } } @@ -172,6 +180,35 @@ private extension ProviderContentModifier { return false } } + + func loadPreferences(for providerId: ProviderID?) { + guard let providerPreferences else { + return + } + if let providerId { + do { + pp_log(.app, .debug, "Load preferences for provider \(providerId)") + providerPreferences.repository = try preferencesManager.preferencesRepository(forProviderWithId: providerId) + } catch { + pp_log(.app, .error, "Unable to load preferences for provider \(providerId): \(error)") + providerPreferences.repository = nil + } + } else { + providerPreferences.repository = nil + } + } + + func savePreferences() { + guard let providerPreferences else { + return + } + do { + pp_log(.app, .debug, "Save preferences for provider \(providerId.debugDescription)") + try providerPreferences.save() + } catch { + pp_log(.app, .error, "Unable to save preferences for provider \(providerId.debugDescription): \(error)") + } + } } // MARK: - Preview @@ -182,6 +219,7 @@ private extension ProviderContentModifier { .modifier(ProviderContentModifier( apis: [API.bundled], providerId: .constant(.hideme), + providerPreferences: nil, entityType: VPNEntity.self, paywallReason: .constant(nil), providerRows: {}, diff --git a/Library/Sources/AppUIMain/Views/VPN/VPNProviderContentModifier.swift b/Library/Sources/AppUIMain/Views/VPN/VPNProviderContentModifier.swift index a9d529f9..36c85a6f 100644 --- a/Library/Sources/AppUIMain/Views/VPN/VPNProviderContentModifier.swift +++ b/Library/Sources/AppUIMain/Views/VPN/VPNProviderContentModifier.swift @@ -34,6 +34,8 @@ struct VPNProviderContentModifier: ViewModifier whe @Binding var providerId: ProviderID? + let providerPreferences: ProviderPreferences? + @Binding var selectedEntity: VPNEntity? @@ -51,6 +53,7 @@ struct VPNProviderContentModifier: ViewModifier whe .modifier(ProviderContentModifier( apis: apis, providerId: $providerId, + providerPreferences: providerPreferences, entityType: VPNEntity.self, paywallReason: $paywallReason, providerRows: { @@ -94,6 +97,7 @@ private extension VPNProviderContentModifier { .modifier(VPNProviderContentModifier( apis: [API.bundled], providerId: .constant(.hideme), + providerPreferences: nil, selectedEntity: .constant(nil as VPNEntity?), paywallReason: .constant(nil), entityDestination: "Destination", diff --git a/Library/Sources/CommonLibrary/Business/PreferencesManager.swift b/Library/Sources/CommonLibrary/Business/PreferencesManager.swift index 479ed33f..18712a4f 100644 --- a/Library/Sources/CommonLibrary/Business/PreferencesManager.swift +++ b/Library/Sources/CommonLibrary/Business/PreferencesManager.swift @@ -28,65 +28,78 @@ import Foundation import PassepartoutKit public final class PreferencesManager: ObservableObject, Sendable { - private let modulesRepository: ModulePreferencesRepository + private let modulesFactory: @Sendable (UUID) throws -> ModulePreferencesRepository private let providersFactory: @Sendable (ProviderID) throws -> ProviderPreferencesRepository public init( - modulesRepository: ModulePreferencesRepository? = nil, + modulesFactory: (@Sendable (UUID) throws -> ModulePreferencesRepository)? = nil, providersFactory: (@Sendable (ProviderID) throws -> ProviderPreferencesRepository)? = nil ) { - self.modulesRepository = modulesRepository ?? DummyModulePreferencesRepository() + self.modulesFactory = modulesFactory ?? { _ in + DummyModulePreferencesRepository() + } self.providersFactory = providersFactory ?? { _ in DummyProviderPreferencesRepository() } } } -// MARK: - Modules - extension PreferencesManager { - public func preferences(forProfile profile: Profile) throws -> [UUID: ModulePreferences] { - try preferences(forModulesWithIds: profile.modules.map(\.id)) + public func preferencesRepository(forModuleWithId moduleId: UUID) throws -> ModulePreferencesRepository { + try modulesFactory(moduleId) } - public func preferences(forProfile editableProfile: EditableProfile) throws -> [UUID: ModulePreferences] { - try preferences(forModulesWithIds: editableProfile.modules.map(\.id)) - } - - public func savePreferences(_ preferences: [UUID: ModulePreferences]) throws { - try modulesRepository.set(preferences) - } -} - -private extension PreferencesManager { - func preferences(forModulesWithIds moduleIds: [UUID]) throws -> [UUID: ModulePreferences] { - try modulesRepository.preferences(for: moduleIds) - } -} - -// MARK: - Providers - -extension PreferencesManager { public func preferencesRepository(forProviderWithId providerId: ProviderID) throws -> ProviderPreferencesRepository { try providersFactory(providerId) } } +@MainActor +extension PreferencesManager { + public func preferences(forModuleWithId moduleId: UUID) throws -> ModulePreferences { + let object = ModulePreferences() + object.repository = try modulesFactory(moduleId) + return object + } + + public func preferences(forProviderWithId providerId: ProviderID) throws -> ProviderPreferences { + let object = ProviderPreferences() + object.repository = try providersFactory(providerId) + return object + } +} + // MARK: - Dummy private final class DummyModulePreferencesRepository: ModulePreferencesRepository { - func preferences(for moduleIds: [UUID]) throws -> [UUID: ModulePreferences] { - [:] + func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool { + false } - func set(_ preferences: [UUID: ModulePreferences]) throws { + func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) { + } + + func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) { + } + + func save() throws { } } private final class DummyProviderPreferencesRepository: ProviderPreferencesRepository { var favoriteServers: Set = [] + func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool { + false + } + + func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) { + } + + func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) { + } + func save() throws { } } diff --git a/Library/Sources/CommonLibrary/Domain/ModulePreferences.swift b/Library/Sources/CommonLibrary/Domain/ModulePreferences.swift index 467be7d0..a10b5b36 100644 --- a/Library/Sources/CommonLibrary/Domain/ModulePreferences.swift +++ b/Library/Sources/CommonLibrary/Domain/ModulePreferences.swift @@ -23,10 +23,28 @@ // along with Passepartout. If not, see . // +import CommonUtils import Foundation import PassepartoutKit -public struct ModulePreferences: Sendable { +@MainActor +public final class ModulePreferences: ObservableObject { + public var repository: ModulePreferencesRepository? + public init() { } + + public func allowedEndpoints() -> Blacklist { + Blacklist { [weak self] in + self?.repository?.isExcludedEndpoint($0) != true + } allow: { [weak self] in + self?.repository?.removeExcludedEndpoint($0) + } deny: { [weak self] in + self?.repository?.addExcludedEndpoint($0) + } + } + + public func save() throws { + try repository?.save() + } } diff --git a/Library/Sources/CommonLibrary/Business/ProviderPreferences.swift b/Library/Sources/CommonLibrary/Domain/ProviderPreferences.swift similarity index 73% rename from Library/Sources/CommonLibrary/Business/ProviderPreferences.swift rename to Library/Sources/CommonLibrary/Domain/ProviderPreferences.swift index a7e54a5b..b7f6aa6d 100644 --- a/Library/Sources/CommonLibrary/Business/ProviderPreferences.swift +++ b/Library/Sources/CommonLibrary/Domain/ProviderPreferences.swift @@ -23,16 +23,13 @@ // along with Passepartout. If not, see . // +import CommonUtils import Foundation import PassepartoutKit @MainActor -public final class ProviderPreferences: ObservableObject, ProviderPreferencesRepository { - public var repository: ProviderPreferencesRepository? { - didSet { - objectWillChange.send() - } - } +public final class ProviderPreferences: ObservableObject { + public var repository: ProviderPreferencesRepository? public init() { } @@ -47,6 +44,16 @@ public final class ProviderPreferences: ObservableObject, ProviderPreferencesRep } } + public func allowedEndpoints() -> Blacklist { + Blacklist { [weak self] in + self?.repository?.isExcludedEndpoint($0) != true + } allow: { [weak self] in + self?.repository?.removeExcludedEndpoint($0) + } deny: { [weak self] in + self?.repository?.addExcludedEndpoint($0) + } + } + public func save() throws { try repository?.save() } diff --git a/Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift b/Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift index 4b2787ad..f6d0d1c2 100644 --- a/Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift +++ b/Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift @@ -26,8 +26,12 @@ import Foundation import PassepartoutKit -public protocol ModulePreferencesRepository: Sendable { - func preferences(for moduleIds: [UUID]) throws -> [UUID: ModulePreferences] +public protocol ModulePreferencesRepository { + func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool - func set(_ preferences: [UUID: ModulePreferences]) throws + func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) + + func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) + + func save() throws } diff --git a/Library/Sources/CommonLibrary/Strategy/ProviderPreferencesRepository.swift b/Library/Sources/CommonLibrary/Strategy/ProviderPreferencesRepository.swift index 477fd7f7..88351944 100644 --- a/Library/Sources/CommonLibrary/Strategy/ProviderPreferencesRepository.swift +++ b/Library/Sources/CommonLibrary/Strategy/ProviderPreferencesRepository.swift @@ -29,5 +29,11 @@ import PassepartoutKit public protocol ProviderPreferencesRepository { var favoriteServers: Set { get set } + func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool + + func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) + + func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) + func save() throws } diff --git a/Library/Sources/CommonUtils/Business/Blacklist.swift b/Library/Sources/CommonUtils/Business/Blacklist.swift new file mode 100644 index 00000000..08485673 --- /dev/null +++ b/Library/Sources/CommonUtils/Business/Blacklist.swift @@ -0,0 +1,59 @@ +// +// Blacklist.swift +// Passepartout +// +// Created by Davide De Rosa on 12/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 . +// + +import Foundation + +@MainActor +public final class Blacklist: ObservableObject where T: Equatable { + private let isAllowed: (T) -> Bool + + private let allow: (T) -> Void + + private let deny: (T) -> Void + + public init( + isAllowed: @escaping (T) -> Bool, + allow: @escaping (T) -> Void, + deny: @escaping (T) -> Void + ) { + self.isAllowed = isAllowed + self.allow = allow + self.deny = deny + } + + public func isAllowed(_ value: T) -> Bool { + isAllowed(value) + } + + public func allow(_ value: T) { + objectWillChange.send() + allow(value) + } + + public func deny(_ value: T) { + objectWillChange.send() + deny(value) + } +} diff --git a/Library/Sources/UILibrary/Business/InteractiveManager.swift b/Library/Sources/UILibrary/Business/InteractiveManager.swift index 89e23e3e..46bd6b61 100644 --- a/Library/Sources/UILibrary/Business/InteractiveManager.swift +++ b/Library/Sources/UILibrary/Business/InteractiveManager.swift @@ -41,9 +41,9 @@ public final class InteractiveManager: ObservableObject { public init() { } - public func present(with profile: Profile, preferencesManager: PreferencesManager, onComplete: CompletionBlock?) { + public func present(with profile: Profile, onComplete: CompletionBlock?) { editor = ProfileEditor() - editor.load(profile.editable(), isShared: false, preferencesManager: preferencesManager) + editor.load(profile.editable(), isShared: false) self.onComplete = onComplete isPresented = true } diff --git a/Library/Sources/UILibrary/Business/ProfileEditor.swift b/Library/Sources/UILibrary/Business/ProfileEditor.swift index a1529a4d..a2445fd1 100644 --- a/Library/Sources/UILibrary/Business/ProfileEditor.swift +++ b/Library/Sources/UILibrary/Business/ProfileEditor.swift @@ -37,8 +37,7 @@ public final class ProfileEditor: ObservableObject { @Published public var isShared: Bool - @Published - public var preferences: [UUID: ModulePreferences] + private var trackedPreferences: [UUID: ModulePreferences] private(set) var removedModules: [UUID: any ModuleBuilder] @@ -50,7 +49,7 @@ public final class ProfileEditor: ObservableObject { public init(profile: Profile) { editableProfile = profile.editable() isShared = false - preferences = [:] + trackedPreferences = [:] removedModules = [:] } @@ -61,7 +60,7 @@ public final class ProfileEditor: ObservableObject { activeModulesIds: Set(modules.map(\.id)) ) isShared = false - preferences = [:] + trackedPreferences = [:] removedModules = [:] } } @@ -202,22 +201,24 @@ extension ProfileEditor { // MARK: - Load/Save extension ProfileEditor { - public func load( - _ profile: EditableProfile, - isShared: Bool, - preferencesManager: PreferencesManager - ) { + public func load(_ profile: EditableProfile, isShared: Bool) { editableProfile = profile self.isShared = isShared - do { - preferences = try preferencesManager.preferences(forProfile: profile) - } catch { - preferences = [:] - pp_log(.App.profiles, .error, "Unable to load preferences for profile \(profile.id): \(error)") - } removedModules = [:] } + public func preferences(forModuleWithId moduleId: UUID, manager: PreferencesManager) -> ModulePreferences { + do { + pp_log(.App.profiles, .debug, "Track preferences for module \(moduleId)") + let observable = try trackedPreferences[moduleId] ?? manager.preferences(forModuleWithId: moduleId) + trackedPreferences[moduleId] = observable + return observable + } catch { + pp_log(.App.profiles, .error, "Unable to track preferences for module \(moduleId): \(error)") + return ModulePreferences() + } + } + @discardableResult public func save( to profileManager: ProfileManager, @@ -226,11 +227,15 @@ extension ProfileEditor { do { let newProfile = try build() try await profileManager.save(newProfile, isLocal: true, remotelyShared: isShared) - do { - try preferencesManager.savePreferences(preferences) - } catch { - pp_log(.App.profiles, .error, "Unable to save preferences for profile \(profile.id): \(error)") + trackedPreferences.forEach { + do { + pp_log(.App.profiles, .debug, "Save tracked preferences for module \($0.key)") + try $0.value.save() + } catch { + pp_log(.App.profiles, .error, "Unable to save preferences for profile \(profile.id): \(error)") + } } + trackedPreferences.removeAll() return newProfile } catch { pp_log(.App.profiles, .fault, "Unable to save edited profile: \(error)") diff --git a/Library/Sources/UILibrary/Extensions/ProfileEditor+UI.swift b/Library/Sources/UILibrary/Extensions/ProfileEditor+UI.swift index 7333d3bc..b9512617 100644 --- a/Library/Sources/UILibrary/Extensions/ProfileEditor+UI.swift +++ b/Library/Sources/UILibrary/Extensions/ProfileEditor+UI.swift @@ -41,12 +41,4 @@ extension ProfileEditor { self?.saveModule($0, activating: false) } } - - public func binding(forPreferencesOf moduleId: UUID) -> Binding { - Binding { [weak self] in - self?.preferences[moduleId] ?? ModulePreferences() - } set: { [weak self] in - self?.preferences[moduleId] = $0 - } - } } diff --git a/Passepartout/Shared/Dependencies+PreferencesManager.swift b/Passepartout/Shared/Dependencies+PreferencesManager.swift index d96592fe..7ae05ab8 100644 --- a/Passepartout/Shared/Dependencies+PreferencesManager.swift +++ b/Passepartout/Shared/Dependencies+PreferencesManager.swift @@ -41,7 +41,9 @@ extension Dependencies { author: nil ) return PreferencesManager( - modulesRepository: AppData.cdModulePreferencesRepositoryV3(context: preferencesStore.context), + modulesFactory: { + try AppData.cdModulePreferencesRepositoryV3(context: preferencesStore.context, moduleId: $0) + }, providersFactory: { try AppData.cdProviderPreferencesRepositoryV3(context: preferencesStore.context, providerId: $0) } diff --git a/Passepartout/Tunnel/Context/DefaultTunnelProcessor.swift b/Passepartout/Tunnel/Context/DefaultTunnelProcessor.swift index 14c9b1cd..88939043 100644 --- a/Passepartout/Tunnel/Context/DefaultTunnelProcessor.swift +++ b/Passepartout/Tunnel/Context/DefaultTunnelProcessor.swift @@ -37,6 +37,32 @@ final class DefaultTunnelProcessor: Sendable { extension DefaultTunnelProcessor: PacketTunnelProcessor { func willStart(_ profile: Profile) throws -> Profile { - profile + do { + var builder = profile.builder() + try builder.modules.forEach { + guard var moduleBuilder = $0.moduleBuilder() as? OpenVPNModule.Builder else { + return + } + + let modulesPreferences = try preferencesManager.preferencesRepository(forModuleWithId: moduleBuilder.id) + moduleBuilder.configurationBuilder?.remotes?.removeAll { + modulesPreferences.isExcludedEndpoint($0) + } + + if let providerId = moduleBuilder.providerId { + let providerPreferences = try preferencesManager.preferencesRepository(forProviderWithId: providerId) + moduleBuilder.configurationBuilder?.remotes?.removeAll { + providerPreferences.isExcludedEndpoint($0) + } + } + + let module = try moduleBuilder.tryBuild() + builder.saveModule(module) + } + return try builder.tryBuild() + } catch { + pp_log(.app, .error, "Unable to process profile, revert to original: \(error)") + return profile + } } }