From aeec943c58ad54e4f6ca7c585d256a646304f0cf Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 10 Dec 2024 11:18:52 +0100 Subject: [PATCH] Move ModulePreferences to Profile.userInfo (#993) Store module preferences in the Profile.userInfo field for atomicity. Access and modification are dramatically simplified, and synchronization comes for free. On the other side, fix provider preferences synchronization by using viewContext for the CloudKit container. Fixes #992 --- .../Domain/CDExcludedEndpoint.swift | 1 - .../Domain/CDModulePreferencesV3.swift | 38 ---- .../Preferences.xcdatamodel/contents | 6 - .../CDModulePreferencesRepositoryV3.swift | 121 ------------- .../AppUIMain/Views/Modules/OpenVPNView.swift | 12 +- .../Business/PreferencesManager.swift | 34 ---- .../Business/ProfileManager.swift | 20 +- .../Domain/EditableProfile.swift | 13 +- .../Domain/ModulePreferences.swift | 46 ----- .../ProfileAttributes+ModulePreferences.swift | 69 +++++++ .../Domain/ProfileAttributes.swift | 171 ++++++++++++------ .../ModulePreferencesRepository.swift | 39 ---- .../UILibrary/Business/ProfileEditor.swift | 34 ---- .../Extensions/ProfileEditor+UI.swift | 21 +++ .../{ => Business}/ExtendedTunnelTests.swift | 0 .../{ => Business}/IAPManagerTests.swift | 0 .../MigrationManagerTests.swift | 0 .../{ => Business}/ProfileManagerTests.swift | 0 .../Domain/ProfileAttributesTests.swift | 123 +++++++++++++ .../Dependencies+PreferencesManager.swift | 8 +- .../Context/DefaultTunnelProcessor.swift | 4 +- 21 files changed, 340 insertions(+), 420 deletions(-) delete mode 100644 Library/Sources/AppDataPreferences/Domain/CDModulePreferencesV3.swift delete mode 100644 Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift delete mode 100644 Library/Sources/CommonLibrary/Domain/ModulePreferences.swift create mode 100644 Library/Sources/CommonLibrary/Domain/ProfileAttributes+ModulePreferences.swift delete mode 100644 Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift rename Library/Tests/CommonLibraryTests/{ => Business}/ExtendedTunnelTests.swift (100%) rename Library/Tests/CommonLibraryTests/{ => Business}/IAPManagerTests.swift (100%) rename Library/Tests/CommonLibraryTests/{ => Business}/MigrationManagerTests.swift (100%) rename Library/Tests/CommonLibraryTests/{ => Business}/ProfileManagerTests.swift (100%) create mode 100644 Library/Tests/CommonLibraryTests/Domain/ProfileAttributesTests.swift diff --git a/Library/Sources/AppDataPreferences/Domain/CDExcludedEndpoint.swift b/Library/Sources/AppDataPreferences/Domain/CDExcludedEndpoint.swift index adb04bb2..79c41a35 100644 --- a/Library/Sources/AppDataPreferences/Domain/CDExcludedEndpoint.swift +++ b/Library/Sources/AppDataPreferences/Domain/CDExcludedEndpoint.swift @@ -33,6 +33,5 @@ final class CDExcludedEndpoint: NSManagedObject { } @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 deleted file mode 100644 index 833bfeb2..00000000 --- a/Library/Sources/AppDataPreferences/Domain/CDModulePreferencesV3.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// CDModulePreferencesV3.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 CoreData -import Foundation - -@objc(CDModulePreferencesV3) -final class CDModulePreferencesV3: NSManagedObject { - @nonobjc static func fetchRequest() -> NSFetchRequest { - NSFetchRequest(entityName: "CDModulePreferencesV3") - } - - @NSManaged var uuid: UUID? - @NSManaged var lastUpdate: Date? - @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 827465fd..a60d1ff3 100644 --- a/Library/Sources/AppDataPreferences/Preferences.xcdatamodeld/Preferences.xcdatamodel/contents +++ b/Library/Sources/AppDataPreferences/Preferences.xcdatamodeld/Preferences.xcdatamodel/contents @@ -2,14 +2,8 @@ - - - - - - diff --git a/Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift b/Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift deleted file mode 100644 index 6a9deea2..00000000 --- a/Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift +++ /dev/null @@ -1,121 +0,0 @@ -// -// 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: "uuid == %@", 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.uuid = 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 { - 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 - } - } - } - - func discard() { - context.performAndWait { - context.rollback() - } - } -} diff --git a/Library/Sources/AppUIMain/Views/Modules/OpenVPNView.swift b/Library/Sources/AppUIMain/Views/Modules/OpenVPNView.swift index 44b4af4c..37a6bdae 100644 --- a/Library/Sources/AppUIMain/Views/Modules/OpenVPNView.swift +++ b/Library/Sources/AppUIMain/Views/Modules/OpenVPNView.swift @@ -51,9 +51,6 @@ struct OpenVPNView: View, ModuleDraftEditing { @State private var paywallReason: PaywallReason? - @StateObject - private var preferences = ModulePreferences() - @StateObject private var providerPreferences = ProviderPreferences() @@ -88,13 +85,6 @@ struct OpenVPNView: View, ModuleDraftEditing { .navigationDestination(for: Subroute.self, destination: destination) .themeAnimation(on: draft.wrappedValue.providerId, category: .modules) .withErrorHandler(errorHandler) - .onLoad { - editor.loadPreferences( - preferences, - from: preferencesManager, - forModuleWithId: module.id - ) - } } } @@ -213,7 +203,7 @@ private extension OpenVPNView { if draft.wrappedValue.providerSelection != nil { return providerPreferences.excludedEndpoints() } else { - return preferences.excludedEndpoints() + return editor.excludedEndpoints(for: module.id) } } diff --git a/Library/Sources/CommonLibrary/Business/PreferencesManager.swift b/Library/Sources/CommonLibrary/Business/PreferencesManager.swift index 53fcd228..a8cc2e49 100644 --- a/Library/Sources/CommonLibrary/Business/PreferencesManager.swift +++ b/Library/Sources/CommonLibrary/Business/PreferencesManager.swift @@ -28,17 +28,11 @@ 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() } @@ -46,10 +40,6 @@ 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) } @@ -57,12 +47,6 @@ extension PreferencesManager { @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) @@ -72,24 +56,6 @@ extension PreferencesManager { // MARK: - Dummy -private final class DummyModulePreferencesRepository: ModulePreferencesRepository { - func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool { - false - } - - func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) { - } - - func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) { - } - - func save() throws { - } - - func discard() { - } -} - private final class DummyProviderPreferencesRepository: ProviderPreferencesRepository { var favoriteServers: Set = [] diff --git a/Library/Sources/CommonLibrary/Business/ProfileManager.swift b/Library/Sources/CommonLibrary/Business/ProfileManager.swift index 732968c3..28db6f3e 100644 --- a/Library/Sources/CommonLibrary/Business/ProfileManager.swift +++ b/Library/Sources/CommonLibrary/Business/ProfileManager.swift @@ -440,15 +440,15 @@ private extension ProfileManager { pp_log(.App.profiles, .info, "Start importing remote profiles: \(profiles.map(\.id))") assert(profiles.count == Set(profiles.map(\.id)).count, "Remote repository must not have duplicates") - pp_log(.App.profiles, .debug, "Local attributes:") - let localAttributes: [Profile.ID: ProfileAttributes] = allProfiles.values.reduce(into: [:]) { - $0[$1.id] = $1.attributes - pp_log(.App.profiles, .debug, "\t\($1.id) = \($1.attributes)") + pp_log(.App.profiles, .debug, "Local fingerprints:") + let localFingerprints: [Profile.ID: UUID] = allProfiles.values.reduce(into: [:]) { + $0[$1.id] = $1.attributes.fingerprint + pp_log(.App.profiles, .debug, "\t\($1.id) = \($1.attributes.fingerprint.debugDescription)") } - pp_log(.App.profiles, .debug, "Remote attributes:") - let remoteAttributes: [Profile.ID: ProfileAttributes] = profiles.reduce(into: [:]) { - $0[$1.id] = $1.attributes - pp_log(.App.profiles, .debug, "\t\($1.id) = \($1.attributes)") + pp_log(.App.profiles, .debug, "Remote fingerprints:") + let remoteFingerprints: [Profile.ID: UUID] = profiles.reduce(into: [:]) { + $0[$1.id] = $1.attributes.fingerprint + pp_log(.App.profiles, .debug, "\t\($1.id) = \($1.attributes.fingerprint.debugDescription)") } let remotelyDeletedIds = Set(allProfiles.keys).subtracting(Set(allRemoteProfiles.keys)) @@ -473,8 +473,8 @@ private extension ProfileManager { idsToRemove.append(remoteProfile.id) continue } - if let localFingerprint = localAttributes[remoteProfile.id]?.fingerprint { - guard let remoteFingerprint = remoteAttributes[remoteProfile.id]?.fingerprint, + if let localFingerprint = localFingerprints[remoteProfile.id] { + guard let remoteFingerprint = remoteFingerprints[remoteProfile.id], remoteFingerprint != localFingerprint else { pp_log(.App.profiles, .info, "Skip re-importing local profile \(remoteProfile.id)") continue diff --git a/Library/Sources/CommonLibrary/Domain/EditableProfile.swift b/Library/Sources/CommonLibrary/Domain/EditableProfile.swift index c4d5ce46..463ddfb3 100644 --- a/Library/Sources/CommonLibrary/Domain/EditableProfile.swift +++ b/Library/Sources/CommonLibrary/Domain/EditableProfile.swift @@ -73,17 +73,6 @@ public struct EditableProfile: MutableProfileType { } } -extension EditableProfile { - public var attributes: ProfileAttributes { - get { - userInfo() ?? ProfileAttributes() - } - set { - setUserInfo(newValue) - } - } -} - extension Profile { public func editable() -> EditableProfile { EditableProfile( @@ -112,6 +101,8 @@ extension Module { } } +// MARK: - + private extension EditableProfile { var activeConnectionModule: (any ModuleBuilder)? { modules.first { diff --git a/Library/Sources/CommonLibrary/Domain/ModulePreferences.swift b/Library/Sources/CommonLibrary/Domain/ModulePreferences.swift deleted file mode 100644 index 8186adc9..00000000 --- a/Library/Sources/CommonLibrary/Domain/ModulePreferences.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// ModulePreferences.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 CommonUtils -import Foundation -import PassepartoutKit - -@MainActor -public final class ModulePreferences: ObservableObject { - public var repository: ModulePreferencesRepository? - - public init() { - } - - 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) - } - } -} diff --git a/Library/Sources/CommonLibrary/Domain/ProfileAttributes+ModulePreferences.swift b/Library/Sources/CommonLibrary/Domain/ProfileAttributes+ModulePreferences.swift new file mode 100644 index 00000000..a5b13842 --- /dev/null +++ b/Library/Sources/CommonLibrary/Domain/ProfileAttributes+ModulePreferences.swift @@ -0,0 +1,69 @@ +// +// ProfileAttributes+ModulePreferences.swift +// Passepartout +// +// Created by Davide De Rosa on 12/9/24. +// Copyright (c) 2024 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +import CommonUtils +import Foundation +import GenericJSON +import PassepartoutKit + +extension ProfileAttributes { + public struct ModulePreferences { + private enum Key: String { + case excludedEndpoints + } + + private(set) var userInfo: [String: AnyHashable] + + init(userInfo: [String: AnyHashable]?) { + self.userInfo = userInfo ?? [:] + } + + public func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool { + excludedEndpoints.contains(endpoint.rawValue) + } + + public mutating func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) { + excludedEndpoints.append(endpoint.rawValue) + } + + public mutating func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) { + let rawValue = endpoint.rawValue + excludedEndpoints.removeAll { + $0 == rawValue + } + } + } +} + +extension ProfileAttributes.ModulePreferences { + var excludedEndpoints: [String] { + get { + userInfo[Key.excludedEndpoints.rawValue] as? [String] ?? [] + } + set { + userInfo[Key.excludedEndpoints.rawValue] = newValue + } + } +} diff --git a/Library/Sources/CommonLibrary/Domain/ProfileAttributes.swift b/Library/Sources/CommonLibrary/Domain/ProfileAttributes.swift index 61b6da66..e768576f 100644 --- a/Library/Sources/CommonLibrary/Domain/ProfileAttributes.swift +++ b/Library/Sources/CommonLibrary/Domain/ProfileAttributes.swift @@ -23,31 +23,122 @@ // along with Passepartout. If not, see . // -import CommonUtils import Foundation import PassepartoutKit -public struct ProfileAttributes: Hashable, Codable { - public var fingerprint: UUID? +// WARNING: upcast to [String: AnyHashable] relies on CodableProfileCoder +// implementation returning JSONSerialization - public var lastUpdate: Date? - - public var isAvailableForTV: Bool? - - public init() { - } - - public init( - fingerprint: UUID?, - lastUpdate: Date?, - isAvailableForTV: Bool? - ) { - self.fingerprint = fingerprint - self.lastUpdate = lastUpdate - self.isAvailableForTV = isAvailableForTV +extension ProfileType where UserInfoType == AnyHashable { + public var attributes: ProfileAttributes { + ProfileAttributes(userInfo: userInfo as? [String: AnyHashable]) } } +extension MutableProfileType where UserInfoType == AnyHashable { + public var attributes: ProfileAttributes { + get { + ProfileAttributes(userInfo: userInfo as? [String: AnyHashable]) + } + set { + userInfo = newValue.userInfo + } + } +} + +// MARK: - ProfileAttributes + +public struct ProfileAttributes { + fileprivate enum Key: String { + case fingerprint + + case lastUpdate + + case isAvailableForTV + + case preferences + } + + private(set) var userInfo: [String: AnyHashable] + + init(userInfo: [String: AnyHashable]?) { + self.userInfo = userInfo ?? [:] + } +} + +// MARK: Basic + +extension ProfileAttributes { + public var fingerprint: UUID? { + get { + guard let string = userInfo[Key.fingerprint.rawValue] as? String else { + return nil + } + return UUID(uuidString: string) + } + set { + userInfo[Key.fingerprint.rawValue] = newValue?.uuidString + } + } + + public var lastUpdate: Date? { + get { + guard let interval = userInfo[Key.lastUpdate.rawValue] as? TimeInterval else { + return nil + } + return Date(timeIntervalSinceReferenceDate: interval) + } + set { + userInfo[Key.lastUpdate.rawValue] = newValue?.timeIntervalSinceReferenceDate + } + } + + public var isAvailableForTV: Bool? { + get { + userInfo[Key.isAvailableForTV.rawValue] as? Bool + } + set { + userInfo[Key.isAvailableForTV.rawValue] = newValue + } + } +} + +// MARK: Preferences + +extension ProfileAttributes { + public func preferences(inModule moduleId: UUID) -> ModulePreferences { + ModulePreferences(userInfo: allPreferences[moduleId.uuidString] as? [String: AnyHashable]) + } + + public mutating func setPreferences(_ module: ModulePreferences, inModule moduleId: UUID) { + allPreferences[moduleId.uuidString] = module.userInfo + } + + public func preference(inModule moduleId: UUID, block: (ModulePreferences) -> T) -> T? { + let module = preferences(inModule: moduleId) + return block(module) + } + + public mutating func editPreferences(inModule moduleId: UUID, block: (inout ModulePreferences) -> Void) { + var module = preferences(inModule: moduleId) + block(&module) + setPreferences(module, inModule: moduleId) + } +} + +private extension ProfileAttributes { + var allPreferences: [String: AnyHashable] { + get { + userInfo[Key.preferences.rawValue] as? [String: AnyHashable] ?? [:] + } + set { + userInfo[Key.preferences.rawValue] = newValue + } + } +} + +// MARK: - + extension ProfileAttributes: CustomDebugStringConvertible { public var debugDescription: String { let descs = [ @@ -59,50 +150,10 @@ extension ProfileAttributes: CustomDebugStringConvertible { }, isAvailableForTV.map { "isAvailableForTV: \($0)" - } + }, + "allPreferences: \(allPreferences)" ].compactMap { $0 } return "{\(descs.joined(separator: ", "))}" } } - -// MARK: - UserInfoCodable - -extension ProfileAttributes: UserInfoCodable { - public init?(userInfo: AnyHashable?) { - do { - let data = try JSONSerialization.data(withJSONObject: userInfo ?? [:]) - self = try JSONDecoder().decode(ProfileAttributes.self, from: data) - } catch { - pp_log(.App.profiles, .error, "Unable to decode ProfileAttributes from dictionary: \(error)") - return nil - } - } - - public var userInfo: AnyHashable? { - do { - let data = try JSONEncoder().encode(self) - return try JSONSerialization.jsonObject(with: data) as? AnyHashable - } catch { - pp_log(.App.profiles, .error, "Unable to encode ProfileAttributes to dictionary: \(error)") - return nil - } - } -} - -extension Profile { - public var attributes: ProfileAttributes { - userInfo() ?? ProfileAttributes() - } -} - -extension Profile.Builder { - public var attributes: ProfileAttributes { - get { - userInfo() ?? ProfileAttributes() - } - set { - setUserInfo(newValue) - } - } -} diff --git a/Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift b/Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift deleted file mode 100644 index be704164..00000000 --- a/Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// ModulePreferencesRepository.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 Foundation -import PassepartoutKit - -public protocol ModulePreferencesRepository { - func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool - - func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) - - func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) - - func save() throws - - func discard() -} diff --git a/Library/Sources/UILibrary/Business/ProfileEditor.swift b/Library/Sources/UILibrary/Business/ProfileEditor.swift index 30d5b52c..bf6e6383 100644 --- a/Library/Sources/UILibrary/Business/ProfileEditor.swift +++ b/Library/Sources/UILibrary/Business/ProfileEditor.swift @@ -37,9 +37,6 @@ public final class ProfileEditor: ObservableObject { @Published public var isShared: Bool - @Published - private var trackedPreferences: [UUID: ModulePreferencesRepository] - private(set) var removedModules: [UUID: any ModuleBuilder] public convenience init() { @@ -50,7 +47,6 @@ public final class ProfileEditor: ObservableObject { public init(profile: Profile) { editableProfile = profile.editable() isShared = false - trackedPreferences = [:] removedModules = [:] } @@ -61,7 +57,6 @@ public final class ProfileEditor: ObservableObject { activeModulesIds: Set(modules.map(\.id)) ) isShared = false - trackedPreferences = [:] removedModules = [:] } } @@ -208,35 +203,11 @@ extension ProfileEditor { removedModules = [:] } - public func loadPreferences( - _ preferences: ModulePreferences, - from manager: PreferencesManager, - forModuleWithId moduleId: UUID - ) { - do { - pp_log(.App.profiles, .debug, "Track preferences for module \(moduleId)") - let repository = try trackedPreferences[moduleId] ?? manager.preferencesRepository(forModuleWithId: moduleId) - preferences.repository = repository - trackedPreferences[moduleId] = repository // @Published - } catch { - pp_log(.App.profiles, .error, "Unable to track preferences for module \(moduleId): \(error)") - } - } - @discardableResult public func save(to profileManager: ProfileManager) async throws -> Profile { do { let newProfile = try build() try await profileManager.save(newProfile, isLocal: true, remotelyShared: isShared) - 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)") @@ -245,11 +216,6 @@ extension ProfileEditor { } public func discard() { - trackedPreferences.forEach { - pp_log(.App.profiles, .debug, "Discard tracked preferences for module \($0.key)") - $0.value.discard() - } - trackedPreferences.removeAll() } } diff --git a/Library/Sources/UILibrary/Extensions/ProfileEditor+UI.swift b/Library/Sources/UILibrary/Extensions/ProfileEditor+UI.swift index b9512617..ec704542 100644 --- a/Library/Sources/UILibrary/Extensions/ProfileEditor+UI.swift +++ b/Library/Sources/UILibrary/Extensions/ProfileEditor+UI.swift @@ -24,6 +24,7 @@ // import CommonLibrary +import CommonUtils import PassepartoutKit import SwiftUI @@ -42,3 +43,23 @@ extension ProfileEditor { } } } + +// MARK: - ModulePreferences + +extension ProfileEditor { + public func excludedEndpoints(for moduleId: UUID) -> ObservableList { + ObservableList { [weak self] endpoint in + self?.profile.attributes.preference(inModule: moduleId) { + $0.isExcludedEndpoint(endpoint) + } ?? false + } add: { [weak self] endpoint in + self?.profile.attributes.editPreferences(inModule: moduleId) { + $0.addExcludedEndpoint(endpoint) + } + } remove: { [weak self] endpoint in + self?.profile.attributes.editPreferences(inModule: moduleId) { + $0.removeExcludedEndpoint(endpoint) + } + } + } +} diff --git a/Library/Tests/CommonLibraryTests/ExtendedTunnelTests.swift b/Library/Tests/CommonLibraryTests/Business/ExtendedTunnelTests.swift similarity index 100% rename from Library/Tests/CommonLibraryTests/ExtendedTunnelTests.swift rename to Library/Tests/CommonLibraryTests/Business/ExtendedTunnelTests.swift diff --git a/Library/Tests/CommonLibraryTests/IAPManagerTests.swift b/Library/Tests/CommonLibraryTests/Business/IAPManagerTests.swift similarity index 100% rename from Library/Tests/CommonLibraryTests/IAPManagerTests.swift rename to Library/Tests/CommonLibraryTests/Business/IAPManagerTests.swift diff --git a/Library/Tests/CommonLibraryTests/MigrationManagerTests.swift b/Library/Tests/CommonLibraryTests/Business/MigrationManagerTests.swift similarity index 100% rename from Library/Tests/CommonLibraryTests/MigrationManagerTests.swift rename to Library/Tests/CommonLibraryTests/Business/MigrationManagerTests.swift diff --git a/Library/Tests/CommonLibraryTests/ProfileManagerTests.swift b/Library/Tests/CommonLibraryTests/Business/ProfileManagerTests.swift similarity index 100% rename from Library/Tests/CommonLibraryTests/ProfileManagerTests.swift rename to Library/Tests/CommonLibraryTests/Business/ProfileManagerTests.swift diff --git a/Library/Tests/CommonLibraryTests/Domain/ProfileAttributesTests.swift b/Library/Tests/CommonLibraryTests/Domain/ProfileAttributesTests.swift new file mode 100644 index 00000000..cf410513 --- /dev/null +++ b/Library/Tests/CommonLibraryTests/Domain/ProfileAttributesTests.swift @@ -0,0 +1,123 @@ +// +// ProfileAttributesTests.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 . +// + +@testable import CommonLibrary +import Foundation +import PassepartoutKit +import XCTest + +final class ProfileAttributesTests: XCTestCase { + func test_givenUserInfo_whenInit_thenReturnsAttributes() { + let fingerprint = UUID() + let lastUpdate = Date() + let isAvailableForTV = true + let userInfo: [String: AnyHashable] = [ + "fingerprint": fingerprint.uuidString, + "lastUpdate": lastUpdate.timeIntervalSinceReferenceDate, + "isAvailableForTV": isAvailableForTV + ] + + let sut = ProfileAttributes(userInfo: userInfo) + XCTAssertEqual(sut.userInfo, userInfo) + XCTAssertEqual(sut.fingerprint, fingerprint) + XCTAssertEqual(sut.lastUpdate, lastUpdate) + XCTAssertEqual(sut.isAvailableForTV, isAvailableForTV) + } + + func test_givenUserInfo_whenSet_thenReturnsAttributes() { + let fingerprint = UUID() + let lastUpdate = Date() + let isAvailableForTV = true + let userInfo: [String: AnyHashable] = [ + "fingerprint": fingerprint.uuidString, + "lastUpdate": lastUpdate.timeIntervalSinceReferenceDate, + "isAvailableForTV": isAvailableForTV + ] + + var sut = ProfileAttributes(userInfo: nil) + sut.fingerprint = fingerprint + sut.lastUpdate = lastUpdate + sut.isAvailableForTV = isAvailableForTV + XCTAssertEqual(sut.userInfo, userInfo) + XCTAssertEqual(sut.fingerprint, fingerprint) + XCTAssertEqual(sut.lastUpdate, lastUpdate) + XCTAssertEqual(sut.isAvailableForTV, isAvailableForTV) + } + + func test_givenUserInfo_whenInit_thenReturnsModulePreferences() { + let moduleId1 = UUID() + let moduleId2 = UUID() + let excludedEndpoints: [String] = [ + "1.1.1.1:UDP6:1000", + "2.2.2.2:TCP4:2000", + "3.3.3.3:TCP:3000", + ] + let moduleUserInfo: [String: AnyHashable] = [ + "excludedEndpoints": excludedEndpoints + ] + let userInfo: [String: AnyHashable] = [ + "preferences": [ + moduleId1.uuidString: moduleUserInfo, + moduleId2.uuidString: moduleUserInfo + ] + ] + + let sut = ProfileAttributes(userInfo: userInfo) + XCTAssertEqual(sut.userInfo, userInfo) + for moduleId in [moduleId1, moduleId2] { + let module = sut.preferences(inModule: moduleId) + XCTAssertEqual(module.userInfo, moduleUserInfo) + XCTAssertEqual(module.excludedEndpoints, excludedEndpoints) + } + } + + func test_givenUserInfo_whenSet_thenReturnsModulePreferences() { + let moduleId1 = UUID() + let moduleId2 = UUID() + let excludedEndpoints: [String] = [ + "1.1.1.1:UDP6:1000", + "2.2.2.2:TCP4:2000", + "3.3.3.3:TCP:3000", + ] + let moduleUserInfo: [String: AnyHashable] = [ + "excludedEndpoints": excludedEndpoints + ] + let userInfo: [String: AnyHashable] = [ + "preferences": [ + moduleId1.uuidString: moduleUserInfo, + moduleId2.uuidString: moduleUserInfo + ] + ] + + var sut = ProfileAttributes(userInfo: nil) + for moduleId in [moduleId1, moduleId2] { + var module = sut.preferences(inModule: moduleId1) + module.excludedEndpoints = excludedEndpoints + XCTAssertEqual(module.userInfo, moduleUserInfo) + sut.setPreferences(module, inModule: moduleId) + } + XCTAssertEqual(sut.userInfo, userInfo) + } +} diff --git a/Passepartout/Shared/Dependencies+PreferencesManager.swift b/Passepartout/Shared/Dependencies+PreferencesManager.swift index f64a3148..b4b8709c 100644 --- a/Passepartout/Shared/Dependencies+PreferencesManager.swift +++ b/Passepartout/Shared/Dependencies+PreferencesManager.swift @@ -41,15 +41,9 @@ extension Dependencies { author: nil ) return PreferencesManager( - modulesFactory: { - try AppData.cdModulePreferencesRepositoryV3( - context: preferencesStore.backgroundContext(), - moduleId: $0 - ) - }, providersFactory: { try AppData.cdProviderPreferencesRepositoryV3( - context: preferencesStore.backgroundContext(), + context: preferencesStore.context, providerId: $0 ) } diff --git a/Passepartout/Tunnel/Context/DefaultTunnelProcessor.swift b/Passepartout/Tunnel/Context/DefaultTunnelProcessor.swift index 88939043..7b80a578 100644 --- a/Passepartout/Tunnel/Context/DefaultTunnelProcessor.swift +++ b/Passepartout/Tunnel/Context/DefaultTunnelProcessor.swift @@ -44,9 +44,9 @@ extension DefaultTunnelProcessor: PacketTunnelProcessor { return } - let modulesPreferences = try preferencesManager.preferencesRepository(forModuleWithId: moduleBuilder.id) + let preferences = builder.attributes.preferences(inModule: moduleBuilder.id) moduleBuilder.configurationBuilder?.remotes?.removeAll { - modulesPreferences.isExcludedEndpoint($0) + preferences.isExcludedEndpoint($0) } if let providerId = moduleBuilder.providerId {