From 6f9c78b25762f482824b7a715254d5dabdd6bec2 Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 10 Dec 2024 14:13:10 +0100 Subject: [PATCH] Track module preferences history in Core Data (#994) Restore CDModulePreferencesV3 to track the history of module prefrences. This way, excluded endpoints may be saved globally to Core Data as a starting point. Then in Profile.userInfo we only save the relevant exclusions for the current configuration. The .excludedEndpoints relationship is therefore moved out of CDProviderPreferencesV3. Further refactoring: - ModuleViewParameters now includes a ModulePreferences observable that module views can observe - Tunnel doesn't need access to PreferencesManager anymore (exclusions are in Profile.userInfo) --- .../Domain/CDExcludedEndpoint.swift | 2 +- .../Domain/CDModulePreferencesV3.swift | 38 ++++++ .../Domain/CDProviderPreferencesV3.swift | 1 - .../Preferences.xcdatamodel/contents | 8 +- .../CDModulePreferencesRepositoryV3.swift | 120 ++++++++++++++++++ .../CDProviderPreferencesRepositoryV3.swift | 30 ----- .../AppUIMain/Views/App/AppCoordinator.swift | 3 - .../Views/Modules/OnDemandView.swift | 1 + .../AppUIMain/Views/Modules/OpenVPNView.swift | 38 ++++-- .../Views/Profile/ModuleDetailView.swift | 23 ++++ .../Views/Profile/ProfileCoordinator.swift | 3 - .../Providers/ProviderContentModifier.swift | 7 +- .../Views/VPN/VPNProviderServerView.swift | 3 +- .../Business/ModulePreferences.swift | 56 ++++++++ .../Business/PreferencesManager.swift | 30 +++-- .../ProviderPreferences.swift | 16 +-- .../ProfileAttributes+ModulePreferences.swift | 17 ++- .../ModulePreferencesRepository.swift | 37 ++++++ .../ProviderPreferencesRepository.swift | 6 - .../Extensions/ModuleBuilder+Previews.swift | 2 + .../Extensions/ProfileEditor+UI.swift | 4 +- .../Strategy/ModuleViewFactory+Default.swift | 3 +- .../Strategy/ModuleViewFactory.swift | 2 +- .../Strategy/ModuleViewProviding.swift | 4 + .../Domain/ProfileAttributesTests.swift | 8 +- Passepartout.xcodeproj/project.pbxproj | 6 - .../App/Context/AppContext+Shared.swift | 26 +++- .../Dependencies+PreferencesManager.swift | 52 -------- .../Context/DefaultTunnelProcessor.swift | 12 +- .../Tunnel/Context/TunnelContext+Shared.swift | 5 +- 30 files changed, 393 insertions(+), 170 deletions(-) create mode 100644 Library/Sources/AppDataPreferences/Domain/CDModulePreferencesV3.swift create mode 100644 Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift create mode 100644 Library/Sources/CommonLibrary/Business/ModulePreferences.swift rename Library/Sources/CommonLibrary/{Domain => Business}/ProviderPreferences.swift (76%) create mode 100644 Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift delete mode 100644 Passepartout/Shared/Dependencies+PreferencesManager.swift diff --git a/Library/Sources/AppDataPreferences/Domain/CDExcludedEndpoint.swift b/Library/Sources/AppDataPreferences/Domain/CDExcludedEndpoint.swift index 79c41a35..3d08a859 100644 --- a/Library/Sources/AppDataPreferences/Domain/CDExcludedEndpoint.swift +++ b/Library/Sources/AppDataPreferences/Domain/CDExcludedEndpoint.swift @@ -33,5 +33,5 @@ final class CDExcludedEndpoint: NSManagedObject { } @NSManaged var endpoint: String? - @NSManaged var providerPreferences: CDProviderPreferencesV3? + @NSManaged var modulePreferences: CDModulePreferencesV3? } diff --git a/Library/Sources/AppDataPreferences/Domain/CDModulePreferencesV3.swift b/Library/Sources/AppDataPreferences/Domain/CDModulePreferencesV3.swift new file mode 100644 index 00000000..6d3a93d7 --- /dev/null +++ b/Library/Sources/AppDataPreferences/Domain/CDModulePreferencesV3.swift @@ -0,0 +1,38 @@ +// +// CDModulePreferencesV3.swift +// Passepartout +// +// Created by Davide De Rosa on 12/10/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 CoreData +import Foundation + +@objc(CDModulePreferencesV3) +final class CDModulePreferencesV3: NSManagedObject { + @nonobjc static func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "CDModulePreferencesV3") + } + + @NSManaged var moduleId: UUID? + @NSManaged var lastUpdate: Date? + @NSManaged var excludedEndpoints: Set? +} diff --git a/Library/Sources/AppDataPreferences/Domain/CDProviderPreferencesV3.swift b/Library/Sources/AppDataPreferences/Domain/CDProviderPreferencesV3.swift index 15ee8171..3d3fabc1 100644 --- a/Library/Sources/AppDataPreferences/Domain/CDProviderPreferencesV3.swift +++ b/Library/Sources/AppDataPreferences/Domain/CDProviderPreferencesV3.swift @@ -35,5 +35,4 @@ final class CDProviderPreferencesV3: NSManagedObject { @NSManaged var providerId: String? @NSManaged var lastUpdate: Date? @NSManaged var favoriteServerIds: Data? - @NSManaged var excludedEndpoints: Set? } diff --git a/Library/Sources/AppDataPreferences/Preferences.xcdatamodeld/Preferences.xcdatamodel/contents b/Library/Sources/AppDataPreferences/Preferences.xcdatamodeld/Preferences.xcdatamodel/contents index a60d1ff3..abd570d6 100644 --- a/Library/Sources/AppDataPreferences/Preferences.xcdatamodeld/Preferences.xcdatamodel/contents +++ b/Library/Sources/AppDataPreferences/Preferences.xcdatamodeld/Preferences.xcdatamodel/contents @@ -2,12 +2,16 @@ - + + + + + + - \ No newline at end of file diff --git a/Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift b/Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift new file mode 100644 index 00000000..6ca884e6 --- /dev/null +++ b/Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift @@ -0,0 +1,120 @@ +// +// CDModulePreferencesRepositoryV3.swift +// Passepartout +// +// Created by Davide De Rosa on 12/5/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 AppData +import CommonLibrary +import CoreData +import Foundation +import PassepartoutKit + +extension AppData { + 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 + + private let entity: CDModulePreferencesV3 + + init(context: NSManagedObjectContext, moduleId: UUID) throws { + self.context = context + + entity = try context.performAndWait { + let request = CDModulePreferencesV3.fetchRequest() + request.predicate = NSPredicate(format: "moduleId == %@", moduleId.uuidString) + request.sortDescriptors = [.init(key: "lastUpdate", ascending: false)] + do { + let entities = try request.execute() + + // dedup by lastUpdate + entities.enumerated().forEach { + guard $0.offset > 0 else { + return + } + $0.element.excludedEndpoints?.forEach(context.delete(_:)) + context.delete($0.element) + } + + let entity = entities.first ?? CDModulePreferencesV3(context: context) + entity.moduleId = moduleId + entity.lastUpdate = Date() + return entity + } catch { + pp_log(.app, .error, "Unable to load preferences for module \(moduleId): \(error)") + throw error + } + } + } + + func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool { + context.performAndWait { + entity.excludedEndpoints?.contains { + $0.endpoint == endpoint.rawValue + } ?? false + } + } + + func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) { + context.performAndWait { + guard entity.excludedEndpoints?.contains(where: { + $0.endpoint == endpoint.rawValue + }) != true else { + return + } + 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 { + try context.performAndWait { + 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 cdd5d32d..5fd0a9e8 100644 --- a/Library/Sources/AppDataPreferences/Strategy/CDProviderPreferencesRepositoryV3.swift +++ b/Library/Sources/AppDataPreferences/Strategy/CDProviderPreferencesRepositoryV3.swift @@ -55,7 +55,6 @@ private final class CDProviderPreferencesRepositoryV3: ProviderPreferencesReposi guard $0.offset > 0 else { return } - $0.element.excludedEndpoints?.forEach(context.delete(_:)) context.delete($0.element) } @@ -95,35 +94,6 @@ 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 { try context.performAndWait { guard context.hasChanges else { diff --git a/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift b/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift index cff37441..03edd4a2 100644 --- a/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift +++ b/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift @@ -34,9 +34,6 @@ public struct AppCoordinator: View, AppCoordinatorConforming, SizeClassProviding @EnvironmentObject public var iapManager: IAPManager - @EnvironmentObject - public var preferencesManager: PreferencesManager - @Environment(\.isUITesting) private var isUITesting diff --git a/Library/Sources/AppUIMain/Views/Modules/OnDemandView.swift b/Library/Sources/AppUIMain/Views/Modules/OnDemandView.swift index 2f0aa232..ce436e0a 100644 --- a/Library/Sources/AppUIMain/Views/Modules/OnDemandView.swift +++ b/Library/Sources/AppUIMain/Views/Modules/OnDemandView.swift @@ -242,6 +242,7 @@ private extension OnDemandView { module: $0, parameters: .init( editor: $1, + preferences: ModulePreferences(), impl: nil ), observer: MockWifi() diff --git a/Library/Sources/AppUIMain/Views/Modules/OpenVPNView.swift b/Library/Sources/AppUIMain/Views/Modules/OpenVPNView.swift index 37a6bdae..3380a8ed 100644 --- a/Library/Sources/AppUIMain/Views/Modules/OpenVPNView.swift +++ b/Library/Sources/AppUIMain/Views/Modules/OpenVPNView.swift @@ -30,9 +30,6 @@ import SwiftUI struct OpenVPNView: View, ModuleDraftEditing { - @EnvironmentObject - private var preferencesManager: PreferencesManager - @Environment(\.navigationPath) private var path @@ -41,6 +38,9 @@ struct OpenVPNView: View, ModuleDraftEditing { @ObservedObject var editor: ProfileEditor + @ObservedObject + var modulePreferences: ModulePreferences + let impl: OpenVPNModule.Implementation? private let isServerPushed: Bool @@ -51,15 +51,13 @@ struct OpenVPNView: View, ModuleDraftEditing { @State private var paywallReason: PaywallReason? - @StateObject - private var providerPreferences = ProviderPreferences() - @StateObject private var errorHandler: ErrorHandler = .default() init(serverConfiguration: OpenVPN.Configuration) { module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder()) editor = ProfileEditor(modules: [module]) + modulePreferences = ModulePreferences() assert(module.configurationBuilder != nil, "isServerPushed must imply module.configurationBuilder != nil") impl = nil isServerPushed = true @@ -68,6 +66,7 @@ struct OpenVPNView: View, ModuleDraftEditing { init(module: OpenVPNModule.Builder, parameters: ModuleViewParameters) { self.module = module editor = parameters.editor + modulePreferences = parameters.preferences impl = parameters.impl as? OpenVPNModule.Implementation isServerPushed = false } @@ -129,7 +128,7 @@ private extension OpenVPNView { var providerModifier: some ViewModifier { VPNProviderContentModifier( providerId: providerId, - providerPreferences: providerPreferences, + providerPreferences: nil, selectedEntity: providerEntity, paywallReason: $paywallReason, entityDestination: Subroute.providerServer, @@ -200,17 +199,32 @@ private extension OpenVPNView { private extension OpenVPNView { var excludedEndpoints: ObservableList { - if draft.wrappedValue.providerSelection != nil { - return providerPreferences.excludedEndpoints() - } else { - return editor.excludedEndpoints(for: module.id) - } + editor.excludedEndpoints(for: module.id, preferences: modulePreferences) } func onSelectServer(server: VPNServer, preset: VPNPreset) { draft.wrappedValue.providerEntity = VPNEntity(server: server, preset: preset) + resetExcludedEndpointsWithCurrentProviderEntity() path.wrappedValue.removeLast() } + + // filter out exclusions unrelated to current server + func resetExcludedEndpointsWithCurrentProviderEntity() { + do { + let cfg = try draft.wrappedValue.providerSelection?.configuration() + editor.profile.attributes.editPreferences(inModule: module.id) { + if let cfg { + $0.excludedEndpoints = Set(cfg.remotes?.filter { + modulePreferences.isExcludedEndpoint($0) + } ?? []) + } else { + $0.excludedEndpoints = [] + } + } + } catch { + pp_log(.app, .error, "Unable to build provider configuration for excluded endpoints: \(error)") + } + } } // MARK: - Previews diff --git a/Library/Sources/AppUIMain/Views/Profile/ModuleDetailView.swift b/Library/Sources/AppUIMain/Views/Profile/ModuleDetailView.swift index 062cb897..81b28413 100644 --- a/Library/Sources/AppUIMain/Views/Profile/ModuleDetailView.swift +++ b/Library/Sources/AppUIMain/Views/Profile/ModuleDetailView.swift @@ -28,12 +28,19 @@ import PassepartoutKit import SwiftUI struct ModuleDetailView: View { + + @EnvironmentObject + private var preferencesManager: PreferencesManager + let profileEditor: ProfileEditor let moduleId: UUID? let moduleViewFactory: any ModuleViewFactory + @StateObject + private var preferences = ModulePreferences() + var body: some View { debugChanges() return Group { @@ -52,8 +59,24 @@ private extension ModuleDetailView { func editorView(forModuleWithId moduleId: UUID) -> some View { AnyView(moduleViewFactory.view( with: profileEditor, + preferences: preferences, moduleId: moduleId )) + .onLoad { + do { + let repository = try preferencesManager.preferencesRepository(forModuleWithId: moduleId) + preferences.setRepository(repository) + } catch { + pp_log(.app, .error, "Unable to load preferences for module \(moduleId): \(error)") + } + } + .onDisappear { + do { + try preferences.save() + } catch { + pp_log(.app, .error, "Unable to save preferences for module \(moduleId): \(error)") + } + } } var emptyView: some View { diff --git a/Library/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift b/Library/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift index 912c2861..d79aee7c 100644 --- a/Library/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift +++ b/Library/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift @@ -43,9 +43,6 @@ struct ProfileCoordinator: View { @EnvironmentObject private var iapManager: IAPManager - @EnvironmentObject - private var preferencesManager: PreferencesManager - let profileManager: ProfileManager let profileEditor: ProfileEditor diff --git a/Library/Sources/AppUIMain/Views/Providers/ProviderContentModifier.swift b/Library/Sources/AppUIMain/Views/Providers/ProviderContentModifier.swift index c45407c9..f88923b1 100644 --- a/Library/Sources/AppUIMain/Views/Providers/ProviderContentModifier.swift +++ b/Library/Sources/AppUIMain/Views/Providers/ProviderContentModifier.swift @@ -188,13 +188,14 @@ private extension ProviderContentModifier { if let providerId { do { pp_log(.app, .debug, "Load preferences for provider \(providerId)") - providerPreferences.repository = try preferencesManager.preferencesRepository(forProviderWithId: providerId) + let repository = try preferencesManager.preferencesRepository(forProviderWithId: providerId) + providerPreferences.setRepository(repository) } catch { pp_log(.app, .error, "Unable to load preferences for provider \(providerId): \(error)") - providerPreferences.repository = nil + providerPreferences.setRepository(nil) } } else { - providerPreferences.repository = nil + providerPreferences.setRepository(nil) } } diff --git a/Library/Sources/AppUIMain/Views/VPN/VPNProviderServerView.swift b/Library/Sources/AppUIMain/Views/VPN/VPNProviderServerView.swift index 620e5d44..dad52cf5 100644 --- a/Library/Sources/AppUIMain/Views/VPN/VPNProviderServerView.swift +++ b/Library/Sources/AppUIMain/Views/VPN/VPNProviderServerView.swift @@ -164,7 +164,8 @@ private extension VPNProviderServerView { private extension VPNProviderServerView { func loadInitialServers() async { do { - providerPreferences.repository = try preferencesManager.preferencesRepository(forProviderWithId: providerId) + let repository = try preferencesManager.preferencesRepository(forProviderWithId: providerId) + providerPreferences.setRepository(repository) } catch { pp_log(.app, .error, "Unable to load preferences for provider \(providerId): \(error)") } diff --git a/Library/Sources/CommonLibrary/Business/ModulePreferences.swift b/Library/Sources/CommonLibrary/Business/ModulePreferences.swift new file mode 100644 index 00000000..25642aff --- /dev/null +++ b/Library/Sources/CommonLibrary/Business/ModulePreferences.swift @@ -0,0 +1,56 @@ +// +// ModulePreferences.swift +// Passepartout +// +// Created by Davide De Rosa on 12/10/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 CommonUtils +import Foundation +import PassepartoutKit + +@MainActor +public final class ModulePreferences: ObservableObject { + private var repository: ModulePreferencesRepository? + + public init() { + } + + public func setRepository(_ repository: ModulePreferencesRepository?) { + self.repository = repository + } + + public func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool { + repository?.isExcludedEndpoint(endpoint) ?? false + } + + public func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) { + repository?.addExcludedEndpoint(endpoint) + } + + public func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) { + repository?.removeExcludedEndpoint(endpoint) + } + + public func save() throws { + try repository?.save() + } +} diff --git a/Library/Sources/CommonLibrary/Business/PreferencesManager.swift b/Library/Sources/CommonLibrary/Business/PreferencesManager.swift index a8cc2e49..ac8f662a 100644 --- a/Library/Sources/CommonLibrary/Business/PreferencesManager.swift +++ b/Library/Sources/CommonLibrary/Business/PreferencesManager.swift @@ -28,11 +28,17 @@ import Foundation import PassepartoutKit public final class PreferencesManager: ObservableObject, Sendable { + private let modulesFactory: @Sendable (UUID) throws -> ModulePreferencesRepository + private let providersFactory: @Sendable (ProviderID) throws -> ProviderPreferencesRepository public init( + modulesFactory: (@Sendable (UUID) throws -> ModulePreferencesRepository)? = nil, providersFactory: (@Sendable (ProviderID) throws -> ProviderPreferencesRepository)? = nil ) { + self.modulesFactory = modulesFactory ?? { _ in + DummyModulePreferencesRepository() + } self.providersFactory = providersFactory ?? { _ in DummyProviderPreferencesRepository() } @@ -40,25 +46,18 @@ public final class PreferencesManager: ObservableObject, Sendable { } extension PreferencesManager { + public func preferencesRepository(forModuleWithId moduleId: UUID) throws -> ModulePreferencesRepository { + try modulesFactory(moduleId) + } + public func preferencesRepository(forProviderWithId providerId: ProviderID) throws -> ProviderPreferencesRepository { try providersFactory(providerId) } } -@MainActor -extension PreferencesManager { - public func preferences(forProviderWithId providerId: ProviderID) throws -> ProviderPreferences { - let object = ProviderPreferences() - object.repository = try providersFactory(providerId) - return object - } -} - // MARK: - Dummy -private final class DummyProviderPreferencesRepository: ProviderPreferencesRepository { - var favoriteServers: Set = [] - +private final class DummyModulePreferencesRepository: ModulePreferencesRepository { func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool { false } @@ -72,3 +71,10 @@ private final class DummyProviderPreferencesRepository: ProviderPreferencesRepos func save() throws { } } + +private final class DummyProviderPreferencesRepository: ProviderPreferencesRepository { + var favoriteServers: Set = [] + + func save() throws { + } +} diff --git a/Library/Sources/CommonLibrary/Domain/ProviderPreferences.swift b/Library/Sources/CommonLibrary/Business/ProviderPreferences.swift similarity index 76% rename from Library/Sources/CommonLibrary/Domain/ProviderPreferences.swift rename to Library/Sources/CommonLibrary/Business/ProviderPreferences.swift index 3afec295..707e2c6a 100644 --- a/Library/Sources/CommonLibrary/Domain/ProviderPreferences.swift +++ b/Library/Sources/CommonLibrary/Business/ProviderPreferences.swift @@ -29,11 +29,15 @@ import PassepartoutKit @MainActor public final class ProviderPreferences: ObservableObject { - public var repository: ProviderPreferencesRepository? + private var repository: ProviderPreferencesRepository? public init() { } + public func setRepository(_ repository: ProviderPreferencesRepository?) { + self.repository = repository + } + public var favoriteServers: Set { get { repository?.favoriteServers ?? [] @@ -44,16 +48,6 @@ public final class ProviderPreferences: ObservableObject { } } - public func excludedEndpoints() -> ObservableList { - ObservableList { [weak self] in - self?.repository?.isExcludedEndpoint($0) == true - } add: { [weak self] in - self?.repository?.addExcludedEndpoint($0) - } remove: { [weak self] in - self?.repository?.removeExcludedEndpoint($0) - } - } - public func save() throws { try repository?.save() } diff --git a/Library/Sources/CommonLibrary/Domain/ProfileAttributes+ModulePreferences.swift b/Library/Sources/CommonLibrary/Domain/ProfileAttributes+ModulePreferences.swift index a5b13842..6a8a0458 100644 --- a/Library/Sources/CommonLibrary/Domain/ProfileAttributes+ModulePreferences.swift +++ b/Library/Sources/CommonLibrary/Domain/ProfileAttributes+ModulePreferences.swift @@ -40,17 +40,26 @@ extension ProfileAttributes { self.userInfo = userInfo ?? [:] } + public var excludedEndpoints: Set { + get { + Set(rawExcludedEndpoints.compactMap(ExtendedEndpoint.init(rawValue:))) + } + set { + rawExcludedEndpoints = newValue.map(\.rawValue) + } + } + public func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool { - excludedEndpoints.contains(endpoint.rawValue) + rawExcludedEndpoints.contains(endpoint.rawValue) } public mutating func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) { - excludedEndpoints.append(endpoint.rawValue) + rawExcludedEndpoints.append(endpoint.rawValue) } public mutating func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) { let rawValue = endpoint.rawValue - excludedEndpoints.removeAll { + rawExcludedEndpoints.removeAll { $0 == rawValue } } @@ -58,7 +67,7 @@ extension ProfileAttributes { } extension ProfileAttributes.ModulePreferences { - var excludedEndpoints: [String] { + var rawExcludedEndpoints: [String] { get { userInfo[Key.excludedEndpoints.rawValue] as? [String] ?? [] } diff --git a/Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift b/Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift new file mode 100644 index 00000000..a0481589 --- /dev/null +++ b/Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift @@ -0,0 +1,37 @@ +// +// ModulePreferencesRepository.swift +// Passepartout +// +// Created by Davide De Rosa on 12/10/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 + +public protocol ModulePreferencesRepository { + func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool + + 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 88351944..477fd7f7 100644 --- a/Library/Sources/CommonLibrary/Strategy/ProviderPreferencesRepository.swift +++ b/Library/Sources/CommonLibrary/Strategy/ProviderPreferencesRepository.swift @@ -29,11 +29,5 @@ 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/UILibrary/Extensions/ModuleBuilder+Previews.swift b/Library/Sources/UILibrary/Extensions/ModuleBuilder+Previews.swift index 9671b92a..5322d5c9 100644 --- a/Library/Sources/UILibrary/Extensions/ModuleBuilder+Previews.swift +++ b/Library/Sources/UILibrary/Extensions/ModuleBuilder+Previews.swift @@ -23,6 +23,7 @@ // along with Passepartout. If not, see . // +import CommonLibrary import PassepartoutKit import SwiftUI @@ -33,6 +34,7 @@ extension ModuleBuilder where Self: ModuleViewProviding { NavigationStack { moduleView(with: .init( editor: ProfileEditor(modules: [self]), + preferences: ModulePreferences(), impl: nil )) .navigationTitle(title) diff --git a/Library/Sources/UILibrary/Extensions/ProfileEditor+UI.swift b/Library/Sources/UILibrary/Extensions/ProfileEditor+UI.swift index ec704542..107abcb1 100644 --- a/Library/Sources/UILibrary/Extensions/ProfileEditor+UI.swift +++ b/Library/Sources/UILibrary/Extensions/ProfileEditor+UI.swift @@ -47,7 +47,7 @@ extension ProfileEditor { // MARK: - ModulePreferences extension ProfileEditor { - public func excludedEndpoints(for moduleId: UUID) -> ObservableList { + public func excludedEndpoints(for moduleId: UUID, preferences: ModulePreferences) -> ObservableList { ObservableList { [weak self] endpoint in self?.profile.attributes.preference(inModule: moduleId) { $0.isExcludedEndpoint(endpoint) @@ -56,10 +56,12 @@ extension ProfileEditor { self?.profile.attributes.editPreferences(inModule: moduleId) { $0.addExcludedEndpoint(endpoint) } + preferences.addExcludedEndpoint(endpoint) } remove: { [weak self] endpoint in self?.profile.attributes.editPreferences(inModule: moduleId) { $0.removeExcludedEndpoint(endpoint) } + preferences.removeExcludedEndpoint(endpoint) } } } diff --git a/Library/Sources/UILibrary/Strategy/ModuleViewFactory+Default.swift b/Library/Sources/UILibrary/Strategy/ModuleViewFactory+Default.swift index df19d309..b41d7763 100644 --- a/Library/Sources/UILibrary/Strategy/ModuleViewFactory+Default.swift +++ b/Library/Sources/UILibrary/Strategy/ModuleViewFactory+Default.swift @@ -36,11 +36,12 @@ public final class DefaultModuleViewFactory: ModuleViewFactory { } @ViewBuilder - public func view(with editor: ProfileEditor, moduleId: UUID) -> some View { + public func view(with editor: ProfileEditor, preferences: ModulePreferences, moduleId: UUID) -> some View { let result = editor.moduleViewProvider(withId: moduleId, registry: registry) if let result { AnyView(result.provider.moduleView(with: .init( editor: editor, + preferences: preferences, impl: result.impl ))) .navigationTitle(result.title) diff --git a/Library/Sources/UILibrary/Strategy/ModuleViewFactory.swift b/Library/Sources/UILibrary/Strategy/ModuleViewFactory.swift index 227f4b17..1b46d1ae 100644 --- a/Library/Sources/UILibrary/Strategy/ModuleViewFactory.swift +++ b/Library/Sources/UILibrary/Strategy/ModuleViewFactory.swift @@ -31,5 +31,5 @@ public protocol ModuleViewFactory: AnyObject { associatedtype Content: View @MainActor - func view(with editor: ProfileEditor, moduleId: UUID) -> Content + func view(with editor: ProfileEditor, preferences: ModulePreferences, moduleId: UUID) -> Content } diff --git a/Library/Sources/UILibrary/Strategy/ModuleViewProviding.swift b/Library/Sources/UILibrary/Strategy/ModuleViewProviding.swift index 9b71dda0..e0d57862 100644 --- a/Library/Sources/UILibrary/Strategy/ModuleViewProviding.swift +++ b/Library/Sources/UILibrary/Strategy/ModuleViewProviding.swift @@ -37,14 +37,18 @@ public protocol ModuleViewProviding { public struct ModuleViewParameters { public let editor: ProfileEditor + public let preferences: ModulePreferences + public let impl: (any ModuleImplementation)? @MainActor public init( editor: ProfileEditor, + preferences: ModulePreferences, impl: (any ModuleImplementation)? ) { self.editor = editor + self.preferences = preferences self.impl = impl } } diff --git a/Library/Tests/CommonLibraryTests/Domain/ProfileAttributesTests.swift b/Library/Tests/CommonLibraryTests/Domain/ProfileAttributesTests.swift index cf410513..20632f9a 100644 --- a/Library/Tests/CommonLibraryTests/Domain/ProfileAttributesTests.swift +++ b/Library/Tests/CommonLibraryTests/Domain/ProfileAttributesTests.swift @@ -72,7 +72,7 @@ final class ProfileAttributesTests: XCTestCase { let excludedEndpoints: [String] = [ "1.1.1.1:UDP6:1000", "2.2.2.2:TCP4:2000", - "3.3.3.3:TCP:3000", + "3.3.3.3:TCP:3000" ] let moduleUserInfo: [String: AnyHashable] = [ "excludedEndpoints": excludedEndpoints @@ -89,7 +89,7 @@ final class ProfileAttributesTests: XCTestCase { for moduleId in [moduleId1, moduleId2] { let module = sut.preferences(inModule: moduleId) XCTAssertEqual(module.userInfo, moduleUserInfo) - XCTAssertEqual(module.excludedEndpoints, excludedEndpoints) + XCTAssertEqual(module.rawExcludedEndpoints, excludedEndpoints) } } @@ -99,7 +99,7 @@ final class ProfileAttributesTests: XCTestCase { let excludedEndpoints: [String] = [ "1.1.1.1:UDP6:1000", "2.2.2.2:TCP4:2000", - "3.3.3.3:TCP:3000", + "3.3.3.3:TCP:3000" ] let moduleUserInfo: [String: AnyHashable] = [ "excludedEndpoints": excludedEndpoints @@ -114,7 +114,7 @@ final class ProfileAttributesTests: XCTestCase { var sut = ProfileAttributes(userInfo: nil) for moduleId in [moduleId1, moduleId2] { var module = sut.preferences(inModule: moduleId1) - module.excludedEndpoints = excludedEndpoints + module.rawExcludedEndpoints = excludedEndpoints XCTAssertEqual(module.userInfo, moduleUserInfo) sut.setPreferences(module, inModule: moduleId) } diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index 1eb150f7..0b3fce12 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -36,8 +36,6 @@ 0E8DFD532D05FE5A00531CDE /* Dependencies+PassepartoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8DFD4C2D05FE5A00531CDE /* Dependencies+PassepartoutKit.swift */; }; 0E8DFD542D05FE5A00531CDE /* Dependencies+IAPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8DFD4B2D05FE5A00531CDE /* Dependencies+IAPManager.swift */; }; 0E8DFD562D05FE5A00531CDE /* Dependencies+CoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8DFD492D05FE5A00531CDE /* Dependencies+CoreData.swift */; }; - 0E8DFD592D05FF0400531CDE /* Dependencies+PreferencesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8DFD582D05FF0400531CDE /* Dependencies+PreferencesManager.swift */; }; - 0E8DFD5A2D05FF0400531CDE /* Dependencies+PreferencesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8DFD582D05FF0400531CDE /* Dependencies+PreferencesManager.swift */; }; 0E916B782CF80FD60072921A /* ProfileEditorScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E916B772CF80FD60072921A /* ProfileEditorScreen.swift */; }; 0E916B7C2CF811EB0072921A /* XCUIElement+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E916B7B2CF811EB0072921A /* XCUIElement+Extensions.swift */; }; 0E94EE582B93554B00588243 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7E3D672B9345FD002BBDB4 /* PacketTunnelProvider.swift */; }; @@ -170,7 +168,6 @@ 0E8DFD4B2D05FE5A00531CDE /* Dependencies+IAPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dependencies+IAPManager.swift"; sourceTree = ""; }; 0E8DFD4C2D05FE5A00531CDE /* Dependencies+PassepartoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dependencies+PassepartoutKit.swift"; sourceTree = ""; }; 0E8DFD4D2D05FE5A00531CDE /* Dependencies+Processors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dependencies+Processors.swift"; sourceTree = ""; }; - 0E8DFD582D05FF0400531CDE /* Dependencies+PreferencesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dependencies+PreferencesManager.swift"; sourceTree = ""; }; 0E916B772CF80FD60072921A /* ProfileEditorScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEditorScreen.swift; sourceTree = ""; }; 0E916B7B2CF811EB0072921A /* XCUIElement+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCUIElement+Extensions.swift"; sourceTree = ""; }; 0E94EE5C2B93570600588243 /* Tunnel.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Tunnel.plist; sourceTree = ""; }; @@ -350,7 +347,6 @@ 0E8DFD492D05FE5A00531CDE /* Dependencies+CoreData.swift */, 0E8DFD4B2D05FE5A00531CDE /* Dependencies+IAPManager.swift */, 0E8DFD4C2D05FE5A00531CDE /* Dependencies+PassepartoutKit.swift */, - 0E8DFD582D05FF0400531CDE /* Dependencies+PreferencesManager.swift */, ); path = Shared; sourceTree = ""; @@ -720,7 +716,6 @@ 0E81955A2CFDA75200CC8FFD /* Dependencies.swift in Sources */, 0E6EEEE32CF8CABA0076E2B0 /* AppContext+Testing.swift in Sources */, 0E6EEEE42CF8CABA0076E2B0 /* ProfileManager+Testing.swift in Sources */, - 0E8DFD592D05FF0400531CDE /* Dependencies+PreferencesManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -769,7 +764,6 @@ 0E8DFD532D05FE5A00531CDE /* Dependencies+PassepartoutKit.swift in Sources */, 0E8DFD542D05FE5A00531CDE /* Dependencies+IAPManager.swift in Sources */, 0E8DFD562D05FE5A00531CDE /* Dependencies+CoreData.swift in Sources */, - 0E8DFD5A2D05FF0400531CDE /* Dependencies+PreferencesManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Passepartout/App/Context/AppContext+Shared.swift b/Passepartout/App/Context/AppContext+Shared.swift index 07ba7dcf..007ebc7c 100644 --- a/Passepartout/App/Context/AppContext+Shared.swift +++ b/Passepartout/App/Context/AppContext+Shared.swift @@ -24,6 +24,7 @@ // import AppData +import AppDataPreferences import AppDataProfiles import AppDataProviders import CommonLibrary @@ -122,7 +123,30 @@ extension AppContext { return MigrationManager(profileStrategy: profileStrategy, simulation: migrationSimulation) }() - let preferencesManager = dependencies.preferencesManager(withCloudKit: true) + let preferencesManager: PreferencesManager = { + let preferencesStore = CoreDataPersistentStore( + logger: dependencies.coreDataLogger(), + containerName: Constants.shared.containers.preferences, + baseURL: BundleConfiguration.urlForGroupDocuments, + model: AppData.cdPreferencesModel, + cloudKitIdentifier: BundleConfiguration.mainString(for: .cloudKitPreferencesId), + author: nil + ) + return PreferencesManager( + modulesFactory: { + try AppData.cdModulePreferencesRepositoryV3( + context: preferencesStore.context, + moduleId: $0 + ) + }, + providersFactory: { + try AppData.cdProviderPreferencesRepositoryV3( + context: preferencesStore.context, + providerId: $0 + ) + } + ) + }() return AppContext( iapManager: iapManager, diff --git a/Passepartout/Shared/Dependencies+PreferencesManager.swift b/Passepartout/Shared/Dependencies+PreferencesManager.swift deleted file mode 100644 index b4b8709c..00000000 --- a/Passepartout/Shared/Dependencies+PreferencesManager.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// Dependencies+PreferencesManager.swift -// Passepartout -// -// Created by Davide De Rosa on 12/2/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 AppData -import AppDataPreferences -import CommonLibrary -import CommonUtils -import Foundation -import PassepartoutKit - -extension Dependencies { - func preferencesManager(withCloudKit: Bool) -> PreferencesManager { - let preferencesStore = CoreDataPersistentStore( - logger: coreDataLogger(), - containerName: Constants.shared.containers.preferences, - baseURL: BundleConfiguration.urlForGroupDocuments, - model: AppData.cdPreferencesModel, - cloudKitIdentifier: withCloudKit ? BundleConfiguration.mainString(for: .cloudKitPreferencesId) : nil, - author: nil - ) - return PreferencesManager( - providersFactory: { - try AppData.cdProviderPreferencesRepositoryV3( - context: preferencesStore.context, - providerId: $0 - ) - } - ) - } -} diff --git a/Passepartout/Tunnel/Context/DefaultTunnelProcessor.swift b/Passepartout/Tunnel/Context/DefaultTunnelProcessor.swift index 7b80a578..64233577 100644 --- a/Passepartout/Tunnel/Context/DefaultTunnelProcessor.swift +++ b/Passepartout/Tunnel/Context/DefaultTunnelProcessor.swift @@ -28,10 +28,7 @@ import Foundation import PassepartoutKit final class DefaultTunnelProcessor: Sendable { - private let preferencesManager: PreferencesManager - - init(preferencesManager: PreferencesManager) { - self.preferencesManager = preferencesManager + init() { } } @@ -49,13 +46,6 @@ extension DefaultTunnelProcessor: PacketTunnelProcessor { preferences.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) } diff --git a/Passepartout/Tunnel/Context/TunnelContext+Shared.swift b/Passepartout/Tunnel/Context/TunnelContext+Shared.swift index c67a3701..fada055d 100644 --- a/Passepartout/Tunnel/Context/TunnelContext+Shared.swift +++ b/Passepartout/Tunnel/Context/TunnelContext+Shared.swift @@ -37,10 +37,7 @@ extension TunnelContext { betaChecker: dependencies.betaChecker(), productsAtBuild: dependencies.productsAtBuild() ) - let processor: PacketTunnelProcessor = { - let preferencesManager = dependencies.preferencesManager(withCloudKit: false) - return DefaultTunnelProcessor(preferencesManager: preferencesManager) - }() + let processor = DefaultTunnelProcessor() return TunnelContext( iapManager: iapManager, processor: processor