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
+ }
}
}