From a4ebea1f95f60c054c0a67bb34780ed288f01658 Mon Sep 17 00:00:00 2001 From: Davide Date: Sun, 8 Dec 2024 16:05:23 +0100 Subject: [PATCH] Handle load/save preferences inside ProfileEditor (#982) Simplify preferences model by doing a bulk load/save together with load/save Profile. ModulePreferences is now a struct rather than an ObservableObject, because it doesn't need ad hoc observation. It's just a binding to ProfileEditor.preferences Fix: - Disable CloudKit in tunnel singleton of PreferencesManager (.sharedForTunnel) Additionally: - Replace MainActor in PreferencesManager with Sendable (immutable) - Replace MainActor from ProviderPreferencesRepository with Sendable (syncs on NSManagedObjectContext) - Drop ModuleMetadata for good --- Library/Package.resolved | 2 +- Library/Package.swift | 2 +- .../AppDataPreferences/Domain/Mapper.swift | 40 +++++++++ .../CDModulePreferencesRepositoryV3.swift | 88 +++++++++++-------- .../CDProviderPreferencesRepositoryV3.swift | 32 ++----- .../Strategy/CDProviderRepositoryV3.swift | 2 +- .../AppUIMain/Views/App/AppCoordinator.swift | 33 ++++--- .../Views/Modules/OnDemandView.swift | 1 - .../Views/Profile/ModuleDetailView.swift | 18 ---- .../Views/Profile/ProfileCoordinator.swift | 14 ++- .../Views/VPN/VPNProviderServerView.swift | 4 +- .../AppUITV/Views/App/AppCoordinator.swift | 9 +- .../Business/PreferencesManager.swift | 59 ++++++++----- .../ProviderPreferences.swift | 20 ++--- .../Domain/EditableProfile.swift | 15 ---- .../Domain/ModulePreferences.swift | 21 +---- .../ModulePreferencesRepository.swift | 8 +- .../ProviderPreferencesRepository.swift | 5 +- .../Business/CoreDataPersistentStore.swift | 4 +- .../Business/InteractiveManager.swift | 5 +- .../UILibrary/Business/ProfileEditor.swift | 55 +++++++++--- .../Extensions/ModuleBuilder+Previews.swift | 1 - .../Extensions/ProfileEditor+UI.swift | 17 ++-- .../Strategy/ModuleViewFactory+Default.swift | 3 +- .../Strategy/ModuleViewFactory.swift | 2 +- .../Strategy/ModuleViewProviding.swift | 4 - .../UILibraryTests/ProfileEditorTests.swift | 2 +- Passepartout/Shared/AppContext+Shared.swift | 4 +- Passepartout/Shared/Shared+App.swift | 4 + Passepartout/Shared/Shared+Tunnel.swift | 4 + Passepartout/Shared/Shared.swift | 18 ++-- .../Shared/Testing/AppContext+Testing.swift | 2 +- .../Tunnel/PacketTunnelProvider.swift | 5 +- 33 files changed, 279 insertions(+), 224 deletions(-) create mode 100644 Library/Sources/AppDataPreferences/Domain/Mapper.swift rename Library/Sources/CommonLibrary/{Domain => Business}/ProviderPreferences.swift (73%) diff --git a/Library/Package.resolved b/Library/Package.resolved index a0560756..2e9bc074 100644 --- a/Library/Package.resolved +++ b/Library/Package.resolved @@ -41,7 +41,7 @@ "kind" : "remoteSourceControl", "location" : "git@github.com:passepartoutvpn/passepartoutkit-source", "state" : { - "revision" : "d753c05f36b789fe413aeccbb543fb8c383ddc2b" + "revision" : "406712a60faf8208a15c4ffaf286b1c71df7c6d2" } }, { diff --git a/Library/Package.swift b/Library/Package.swift index 809dc11b..88dd3cda 100644 --- a/Library/Package.swift +++ b/Library/Package.swift @@ -65,7 +65,7 @@ let package = Package( ], dependencies: [ // .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.12.0"), - .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "d753c05f36b789fe413aeccbb543fb8c383ddc2b"), + .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "406712a60faf8208a15c4ffaf286b1c71df7c6d2"), // .package(path: "../../passepartoutkit-source"), .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", from: "0.9.1"), // .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"), diff --git a/Library/Sources/AppDataPreferences/Domain/Mapper.swift b/Library/Sources/AppDataPreferences/Domain/Mapper.swift new file mode 100644 index 00000000..ccbb0979 --- /dev/null +++ b/Library/Sources/AppDataPreferences/Domain/Mapper.swift @@ -0,0 +1,40 @@ +// +// Mapper.swift +// Passepartout +// +// Created by Davide De Rosa on 12/7/24. +// Copyright (c) 2024 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +import CommonLibrary +import CoreData +import Foundation +import PassepartoutKit + +struct DomainMapper { + func preferences(from entity: CDModulePreferencesV3) throws -> ModulePreferences { + ModulePreferences() + } +} + +struct CoreDataMapper { + func set(_ entity: CDModulePreferencesV3, from preferences: ModulePreferences) throws { + } +} diff --git a/Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift b/Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift index 007fd49b..d279797f 100644 --- a/Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift +++ b/Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift @@ -30,15 +30,11 @@ import Foundation import PassepartoutKit extension AppData { - - @MainActor public static func cdModulePreferencesRepositoryV3(context: NSManagedObjectContext) -> ModulePreferencesRepository { CDModulePreferencesRepositoryV3(context: context) } } -// MARK: - Repository - private final class CDModulePreferencesRepositoryV3: ModulePreferencesRepository { private nonisolated let context: NSManagedObjectContext @@ -46,44 +42,62 @@ private final class CDModulePreferencesRepositoryV3: ModulePreferencesRepository self.context = context } - func modulePreferencesProxy(in moduleId: UUID) throws -> ModulePreferencesProxy { - let entity = try context.performAndWait { + func preferences(for moduleIds: [UUID]) throws -> [UUID: ModulePreferences] { + try context.performAndWait { let request = CDModulePreferencesV3.fetchRequest() - request.predicate = NSPredicate(format: "uuid == %@", moduleId.uuidString) + 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 + } do { - let entity = try request.execute().first ?? CDModulePreferencesV3(context: context) - entity.uuid = moduleId - return entity + try context.save() } catch { - pp_log(.app, .error, "Unable to load preferences for module \(moduleId): \(error)") + context.rollback() throw error } } - return CDModulePreferencesProxy(context: context, entity: entity) - } -} - -// MARK: - Preference - -private final class CDModulePreferencesProxy: ModulePreferencesProxy { - private let context: NSManagedObjectContext - - private let entity: CDModulePreferencesV3 - - init(context: NSManagedObjectContext, entity: CDModulePreferencesV3) { - self.context = context - self.entity = entity - } - - func save() throws { - guard context.hasChanges else { - return - } - do { - try context.save() - } catch { - context.rollback() - throw error - } + } + + func rollback() { + context.rollback() } } diff --git a/Library/Sources/AppDataPreferences/Strategy/CDProviderPreferencesRepositoryV3.swift b/Library/Sources/AppDataPreferences/Strategy/CDProviderPreferencesRepositoryV3.swift index ace7bc1b..fd86e42e 100644 --- a/Library/Sources/AppDataPreferences/Strategy/CDProviderPreferencesRepositoryV3.swift +++ b/Library/Sources/AppDataPreferences/Strategy/CDProviderPreferencesRepositoryV3.swift @@ -30,24 +30,20 @@ import Foundation import PassepartoutKit extension AppData { - - @MainActor - public static func cdProviderPreferencesRepositoryV3(context: NSManagedObjectContext) -> ProviderPreferencesRepository { - CDProviderPreferencesRepositoryV3(context: context) + public static func cdProviderPreferencesRepositoryV3(context: NSManagedObjectContext, providerId: ProviderID) throws -> ProviderPreferencesRepository { + try CDProviderPreferencesRepositoryV3(context: context, providerId: providerId) } } -// MARK: - Repository - private final class CDProviderPreferencesRepositoryV3: ProviderPreferencesRepository { private nonisolated let context: NSManagedObjectContext - init(context: NSManagedObjectContext) { - self.context = context - } + private let entity: CDProviderPreferencesV3 - func providerPreferencesProxy(in providerId: ProviderID) throws -> ProviderPreferencesProxy { - let entity = try context.performAndWait { + init(context: NSManagedObjectContext, providerId: ProviderID) throws { + self.context = context + + entity = try context.performAndWait { let request = CDProviderPreferencesV3.fetchRequest() request.predicate = NSPredicate(format: "providerId == %@", providerId.rawValue) do { @@ -59,20 +55,6 @@ private final class CDProviderPreferencesRepositoryV3: ProviderPreferencesReposi throw error } } - return CDProviderPreferencesProxy(context: context, entity: entity) - } -} - -// MARK: - Preference - -private final class CDProviderPreferencesProxy: ProviderPreferencesProxy { - private let context: NSManagedObjectContext - - private let entity: CDProviderPreferencesV3 - - init(context: NSManagedObjectContext, entity: CDProviderPreferencesV3) { - self.context = context - self.entity = entity } var favoriteServers: Set { diff --git a/Library/Sources/AppDataProviders/Strategy/CDProviderRepositoryV3.swift b/Library/Sources/AppDataProviders/Strategy/CDProviderRepositoryV3.swift index 0fff2ec9..14656166 100644 --- a/Library/Sources/AppDataProviders/Strategy/CDProviderRepositoryV3.swift +++ b/Library/Sources/AppDataProviders/Strategy/CDProviderRepositoryV3.swift @@ -36,7 +36,7 @@ extension AppData { } } -private actor CDProviderRepositoryV3: NSObject, ProviderRepository { +private final class CDProviderRepositoryV3: NSObject, ProviderRepository { private nonisolated let context: NSManagedObjectContext private nonisolated let providersSubject: CurrentValueSubject<[Provider], Never> diff --git a/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift b/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift index 453e64b4..192bfbc1 100644 --- a/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift +++ b/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift @@ -34,6 +34,9 @@ public struct AppCoordinator: View, AppCoordinatorConforming, SizeClassProviding @EnvironmentObject public var iapManager: IAPManager + @EnvironmentObject + public var preferencesManager: PreferencesManager + @Environment(\.isUITesting) private var isUITesting @@ -117,12 +120,7 @@ extension AppCoordinator { isImporting: $isImporting, errorHandler: errorHandler, flow: .init( - onEditProfile: { - guard let profile = profileManager.profile(withId: $0.id) else { - return - } - enterDetail(of: profile.editable(), initialModuleId: nil) - }, + onEditProfile: onEditProfile, onMigrateProfiles: { modalRoute = .migrateProfiles }, @@ -168,7 +166,7 @@ extension AppCoordinator { onMigrateProfiles: { present(.migrateProfiles) }, - onNewProfile: enterDetail + onNewProfile: onNewProfile ) } @@ -232,7 +230,11 @@ extension AppCoordinator { extension AppCoordinator { public func onInteractiveLogin(_ profile: Profile, _ onComplete: @escaping InteractiveManager.CompletionBlock) { pp_log(.app, .info, "Present interactive login") - interactiveManager.present(with: profile, onComplete: onComplete) + interactiveManager.present( + with: profile, + preferencesManager: preferencesManager, + onComplete: onComplete + ) } public func onProviderEntityRequired(_ profile: Profile, force: Bool) { @@ -287,10 +289,21 @@ private extension AppCoordinator { } } - func enterDetail(of profile: EditableProfile, initialModuleId: UUID?) { + func onNewProfile(_ profile: EditableProfile, initialModuleId: UUID?) { + editProfile(profile, initialModuleId: initialModuleId) + } + + func onEditProfile(_ preview: ProfilePreview) { + guard let profile = profileManager.profile(withId: preview.id) else { + return + } + editProfile(profile.editable(), initialModuleId: nil) + } + + func editProfile(_ profile: EditableProfile, initialModuleId: UUID?) { profilePath = NavigationPath() let isShared = profileManager.isRemotelyShared(profileWithId: profile.id) - profileEditor.editProfile(profile, isShared: isShared) + profileEditor.load(profile, isShared: isShared, preferencesManager: preferencesManager) present(.editProfile(initialModuleId)) } } diff --git a/Library/Sources/AppUIMain/Views/Modules/OnDemandView.swift b/Library/Sources/AppUIMain/Views/Modules/OnDemandView.swift index f027a843..2f0aa232 100644 --- a/Library/Sources/AppUIMain/Views/Modules/OnDemandView.swift +++ b/Library/Sources/AppUIMain/Views/Modules/OnDemandView.swift @@ -242,7 +242,6 @@ private extension OnDemandView { module: $0, parameters: .init( editor: $1, - preferences: nil, impl: nil ), observer: MockWifi() diff --git a/Library/Sources/AppUIMain/Views/Profile/ModuleDetailView.swift b/Library/Sources/AppUIMain/Views/Profile/ModuleDetailView.swift index d13fe879..062cb897 100644 --- a/Library/Sources/AppUIMain/Views/Profile/ModuleDetailView.swift +++ b/Library/Sources/AppUIMain/Views/Profile/ModuleDetailView.swift @@ -28,19 +28,12 @@ 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 modulePreferences = ModulePreferences(proxy: nil) - var body: some View { debugChanges() return Group { @@ -50,16 +43,6 @@ struct ModuleDetailView: View { emptyView } } - .onLoad { - guard let moduleId else { - return - } - do { - modulePreferences.proxy = try preferencesManager.modulePreferencesProxy(in: moduleId) - } catch { - pp_log(.app, .error, "Unable to load module preferences: \(error)") - } - } } } @@ -69,7 +52,6 @@ private extension ModuleDetailView { func editorView(forModuleWithId moduleId: UUID) -> some View { AnyView(moduleViewFactory.view( with: profileEditor, - preferences: modulePreferences, moduleId: moduleId )) } diff --git a/Library/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift b/Library/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift index 2ff12892..80a2c10c 100644 --- a/Library/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift +++ b/Library/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift @@ -43,6 +43,9 @@ struct ProfileCoordinator: View { @EnvironmentObject private var iapManager: IAPManager + @EnvironmentObject + private var preferencesManager: PreferencesManager + let profileManager: ProfileManager let profileEditor: ProfileEditor @@ -133,7 +136,10 @@ private extension ProfileCoordinator { // standard: always save, warn if purchase required func onCommitEditingStandard() async throws { - let savedProfile = try await profileEditor.save(to: profileManager) + let savedProfile = try await profileEditor.save( + to: profileManager, + preferencesManager: preferencesManager + ) do { try iapManager.verify(savedProfile) } catch AppError.ineligibleProfile(let requiredFeatures) { @@ -151,11 +157,15 @@ private extension ProfileCoordinator { paywallReason = .init(requiredFeatures) return } - try await profileEditor.save(to: profileManager) + try await profileEditor.save( + to: profileManager, + preferencesManager: preferencesManager + ) onDismiss() } func onCancelEditing() { + profileEditor.discard() onDismiss() } } diff --git a/Library/Sources/AppUIMain/Views/VPN/VPNProviderServerView.swift b/Library/Sources/AppUIMain/Views/VPN/VPNProviderServerView.swift index c1a0b90f..75c05fa1 100644 --- a/Library/Sources/AppUIMain/Views/VPN/VPNProviderServerView.swift +++ b/Library/Sources/AppUIMain/Views/VPN/VPNProviderServerView.swift @@ -68,7 +68,7 @@ struct VPNProviderServerView: View where Configuration: Identifia private var onlyShowsFavorites = false @StateObject - private var providerPreferences = ProviderPreferences(proxy: nil) + private var providerPreferences = ProviderPreferences() @StateObject private var filtersViewModel = VPNFiltersView.Model() @@ -159,7 +159,7 @@ private extension VPNProviderServerView { private extension VPNProviderServerView { func loadInitialServers() async { do { - providerPreferences.proxy = try preferencesManager.providerPreferencesProxy(in: providerId) + providerPreferences.repository = try preferencesManager.preferencesRepository(forProviderWithId: providerId) } catch { pp_log(.app, .error, "Unable to load preferences for provider \(providerId): \(error)") } diff --git a/Library/Sources/AppUITV/Views/App/AppCoordinator.swift b/Library/Sources/AppUITV/Views/App/AppCoordinator.swift index c9189652..28b76f90 100644 --- a/Library/Sources/AppUITV/Views/App/AppCoordinator.swift +++ b/Library/Sources/AppUITV/Views/App/AppCoordinator.swift @@ -34,6 +34,9 @@ public struct AppCoordinator: View, AppCoordinatorConforming { @EnvironmentObject public var iapManager: IAPManager + @EnvironmentObject + private var preferencesManager: PreferencesManager + private let profileManager: ProfileManager public let tunnel: ExtendedTunnel @@ -136,7 +139,11 @@ private extension AppCoordinator { extension AppCoordinator { public func onInteractiveLogin(_ profile: Profile, _ onComplete: @escaping InteractiveManager.CompletionBlock) { pp_log(.app, .info, "Present interactive login") - interactiveManager.present(with: profile, onComplete: onComplete) + interactiveManager.present( + with: profile, + preferencesManager: preferencesManager, + onComplete: onComplete + ) } public func onProviderEntityRequired(_ profile: Profile, force: Bool) { diff --git a/Library/Sources/CommonLibrary/Business/PreferencesManager.swift b/Library/Sources/CommonLibrary/Business/PreferencesManager.swift index 8db9d4f5..479ed33f 100644 --- a/Library/Sources/CommonLibrary/Business/PreferencesManager.swift +++ b/Library/Sources/CommonLibrary/Business/PreferencesManager.swift @@ -27,51 +27,66 @@ import CommonUtils import Foundation import PassepartoutKit -@MainActor -public final class PreferencesManager: ObservableObject { +public final class PreferencesManager: ObservableObject, Sendable { private let modulesRepository: ModulePreferencesRepository - private let providersRepository: ProviderPreferencesRepository + private let providersFactory: @Sendable (ProviderID) throws -> ProviderPreferencesRepository public init( modulesRepository: ModulePreferencesRepository? = nil, - providersRepository: ProviderPreferencesRepository? = nil + providersFactory: (@Sendable (ProviderID) throws -> ProviderPreferencesRepository)? = nil ) { self.modulesRepository = modulesRepository ?? DummyModulePreferencesRepository() - self.providersRepository = providersRepository ?? DummyProviderPreferencesRepository() + 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 modulePreferencesProxy(in moduleId: UUID) throws -> ModulePreferencesProxy { - try modulesRepository.modulePreferencesProxy(in: moduleId) + public func preferences(forProfile editableProfile: EditableProfile) throws -> [UUID: ModulePreferences] { + try preferences(forModulesWithIds: editableProfile.modules.map(\.id)) } - public func providerPreferencesProxy(in providerId: ProviderID) throws -> ProviderPreferencesProxy { - try providersRepository.providerPreferencesProxy(in: providerId) + 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) } } // MARK: - Dummy private final class DummyModulePreferencesRepository: ModulePreferencesRepository { - private final class Proxy: ModulePreferencesProxy { - func save() throws { - } + func preferences(for moduleIds: [UUID]) throws -> [UUID: ModulePreferences] { + [:] } - func modulePreferencesProxy(in moduleId: UUID) throws -> ModulePreferencesProxy { - Proxy() + func set(_ preferences: [UUID: ModulePreferences]) throws { } } private final class DummyProviderPreferencesRepository: ProviderPreferencesRepository { - private final class Proxy: ProviderPreferencesProxy { - var favoriteServers: Set = [] + var favoriteServers: Set = [] - func save() throws { - } - } - - func providerPreferencesProxy(in providerId: ProviderID) throws -> ProviderPreferencesProxy { - Proxy() + func save() throws { } } diff --git a/Library/Sources/CommonLibrary/Domain/ProviderPreferences.swift b/Library/Sources/CommonLibrary/Business/ProviderPreferences.swift similarity index 73% rename from Library/Sources/CommonLibrary/Domain/ProviderPreferences.swift rename to Library/Sources/CommonLibrary/Business/ProviderPreferences.swift index 81f42bc8..a7e54a5b 100644 --- a/Library/Sources/CommonLibrary/Domain/ProviderPreferences.swift +++ b/Library/Sources/CommonLibrary/Business/ProviderPreferences.swift @@ -27,35 +27,27 @@ import Foundation import PassepartoutKit @MainActor -public final class ProviderPreferences: ObservableObject { - public var proxy: ProviderPreferencesProxy? { +public final class ProviderPreferences: ObservableObject, ProviderPreferencesRepository { + public var repository: ProviderPreferencesRepository? { didSet { objectWillChange.send() } } - public init(proxy: ProviderPreferencesProxy?) { - self.proxy = proxy + public init() { } public var favoriteServers: Set { get { - proxy?.favoriteServers ?? [] + repository?.favoriteServers ?? [] } set { objectWillChange.send() - proxy?.favoriteServers = newValue + repository?.favoriteServers = newValue } } public func save() throws { - try proxy?.save() + try repository?.save() } } - -@MainActor -public protocol ProviderPreferencesProxy { - var favoriteServers: Set { get set } - - func save() throws -} diff --git a/Library/Sources/CommonLibrary/Domain/EditableProfile.swift b/Library/Sources/CommonLibrary/Domain/EditableProfile.swift index ffd53dda..c4d5ce46 100644 --- a/Library/Sources/CommonLibrary/Domain/EditableProfile.swift +++ b/Library/Sources/CommonLibrary/Domain/EditableProfile.swift @@ -35,8 +35,6 @@ public struct EditableProfile: MutableProfileType { public var activeModulesIds: Set - public var modulesMetadata: [UUID: ModuleMetadata]? - public var userInfo: AnyHashable? public init( @@ -44,14 +42,12 @@ public struct EditableProfile: MutableProfileType { name: String = "", modules: [any ModuleBuilder] = [], activeModulesIds: Set = [], - modulesMetadata: [UUID: ModuleMetadata]? = nil, userInfo: AnyHashable? = nil ) { self.id = id self.name = name self.modules = modules self.activeModulesIds = activeModulesIds - self.modulesMetadata = modulesMetadata self.userInfo = userInfo } @@ -71,16 +67,6 @@ public struct EditableProfile: MutableProfileType { throw AppError.emptyProfileName } builder.name = trimmedName - - builder.modulesMetadata = modulesMetadata?.reduce(into: [:]) { - var metadata = $1.value - if var trimmedName = metadata.name { - trimmedName = trimmedName.trimmingCharacters(in: .whitespaces) - metadata.name = !trimmedName.isEmpty ? trimmedName : nil - } - $0[$1.key] = metadata - } - builder.userInfo = userInfo return builder @@ -105,7 +91,6 @@ extension Profile { name: name, modules: modulesBuilders(), activeModulesIds: activeModulesIds, - modulesMetadata: modulesMetadata, userInfo: userInfo ) } diff --git a/Library/Sources/CommonLibrary/Domain/ModulePreferences.swift b/Library/Sources/CommonLibrary/Domain/ModulePreferences.swift index 5a5ad185..467be7d0 100644 --- a/Library/Sources/CommonLibrary/Domain/ModulePreferences.swift +++ b/Library/Sources/CommonLibrary/Domain/ModulePreferences.swift @@ -26,24 +26,7 @@ import Foundation import PassepartoutKit -@MainActor -public final class ModulePreferences: ObservableObject { - public var proxy: ModulePreferencesProxy? { - didSet { - objectWillChange.send() - } - } - - public init(proxy: ModulePreferencesProxy?) { - self.proxy = proxy - } - - public func save() throws { - try proxy?.save() +public struct ModulePreferences: Sendable { + public init() { } } - -@MainActor -public protocol ModulePreferencesProxy { - func save() throws -} diff --git a/Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift b/Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift index 5f911a29..4b2787ad 100644 --- a/Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift +++ b/Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift @@ -24,8 +24,10 @@ // import Foundation +import PassepartoutKit -@MainActor -public protocol ModulePreferencesRepository { - func modulePreferencesProxy(in moduleId: UUID) throws -> ModulePreferencesProxy +public protocol ModulePreferencesRepository: Sendable { + func preferences(for moduleIds: [UUID]) throws -> [UUID: ModulePreferences] + + func set(_ preferences: [UUID: ModulePreferences]) throws } diff --git a/Library/Sources/CommonLibrary/Strategy/ProviderPreferencesRepository.swift b/Library/Sources/CommonLibrary/Strategy/ProviderPreferencesRepository.swift index 7454a77d..477fd7f7 100644 --- a/Library/Sources/CommonLibrary/Strategy/ProviderPreferencesRepository.swift +++ b/Library/Sources/CommonLibrary/Strategy/ProviderPreferencesRepository.swift @@ -26,7 +26,8 @@ import Foundation import PassepartoutKit -@MainActor public protocol ProviderPreferencesRepository { - func providerPreferencesProxy(in providerId: ProviderID) throws -> ProviderPreferencesProxy + var favoriteServers: Set { get set } + + func save() throws } diff --git a/Library/Sources/CommonUtils/Business/CoreDataPersistentStore.swift b/Library/Sources/CommonUtils/Business/CoreDataPersistentStore.swift index e746410d..ff74fd8a 100644 --- a/Library/Sources/CommonUtils/Business/CoreDataPersistentStore.swift +++ b/Library/Sources/CommonUtils/Business/CoreDataPersistentStore.swift @@ -28,13 +28,13 @@ import Combine import CoreData import Foundation -public protocol CoreDataPersistentStoreLogger { +public protocol CoreDataPersistentStoreLogger: Sendable { func debug(_ msg: String) func warning(_ msg: String) } -public final class CoreDataPersistentStore { +public final class CoreDataPersistentStore: Sendable { private let logger: CoreDataPersistentStoreLogger? private let container: NSPersistentContainer diff --git a/Library/Sources/UILibrary/Business/InteractiveManager.swift b/Library/Sources/UILibrary/Business/InteractiveManager.swift index 05c02be1..89e23e3e 100644 --- a/Library/Sources/UILibrary/Business/InteractiveManager.swift +++ b/Library/Sources/UILibrary/Business/InteractiveManager.swift @@ -41,8 +41,9 @@ public final class InteractiveManager: ObservableObject { public init() { } - public func present(with profile: Profile, onComplete: CompletionBlock?) { - editor = ProfileEditor(profile: profile) + public func present(with profile: Profile, preferencesManager: PreferencesManager, onComplete: CompletionBlock?) { + editor = ProfileEditor() + editor.load(profile.editable(), isShared: false, preferencesManager: preferencesManager) self.onComplete = onComplete isPresented = true } diff --git a/Library/Sources/UILibrary/Business/ProfileEditor.swift b/Library/Sources/UILibrary/Business/ProfileEditor.swift index a220274e..12429566 100644 --- a/Library/Sources/UILibrary/Business/ProfileEditor.swift +++ b/Library/Sources/UILibrary/Business/ProfileEditor.swift @@ -37,30 +37,31 @@ public final class ProfileEditor: ObservableObject { @Published public var isShared: Bool + @Published + public var preferences: [UUID: ModulePreferences] + private(set) var removedModules: [UUID: any ModuleBuilder] public convenience init() { self.init(modules: []) } + // for testing/previews + public init(profile: Profile) { + editableProfile = profile.editable() + isShared = false + preferences = [:] + removedModules = [:] + } + + // for testing/previews public init(modules: [any ModuleBuilder]) { editableProfile = EditableProfile( modules: modules, activeModulesIds: Set(modules.map(\.id)) ) isShared = false - removedModules = [:] - } - - public init(profile: Profile) { - editableProfile = profile.editable() - isShared = false - removedModules = [:] - } - - public func editProfile(_ profile: EditableProfile, isShared: Bool) { - editableProfile = profile - self.isShared = isShared + preferences = [:] removedModules = [:] } } @@ -198,21 +199,47 @@ extension ProfileEditor { } } -// MARK: - Saving +// MARK: - Load/Save extension ProfileEditor { + public func load( + _ profile: EditableProfile, + isShared: Bool, + preferencesManager: PreferencesManager + ) { + editableProfile = profile + self.isShared = isShared + do { + preferences = try preferencesManager.preferences(forProfile: profile) + } catch { + preferences = [:] + pp_log(.app, .error, "Unable to load preferences for profile \(profile.id): \(error)") + } + removedModules = [:] + } @discardableResult - public func save(to profileManager: ProfileManager) async throws -> Profile { + public func save( + to profileManager: ProfileManager, + preferencesManager: PreferencesManager + ) async throws -> Profile { 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)") + } return newProfile } catch { pp_log(.app, .fault, "Unable to save edited profile: \(error)") throw error } } + + public func discard() { + } } // MARK: - Testing diff --git a/Library/Sources/UILibrary/Extensions/ModuleBuilder+Previews.swift b/Library/Sources/UILibrary/Extensions/ModuleBuilder+Previews.swift index 49610f19..9671b92a 100644 --- a/Library/Sources/UILibrary/Extensions/ModuleBuilder+Previews.swift +++ b/Library/Sources/UILibrary/Extensions/ModuleBuilder+Previews.swift @@ -33,7 +33,6 @@ extension ModuleBuilder where Self: ModuleViewProviding { NavigationStack { moduleView(with: .init( editor: ProfileEditor(modules: [self]), - preferences: nil, impl: nil )) .navigationTitle(title) diff --git a/Library/Sources/UILibrary/Extensions/ProfileEditor+UI.swift b/Library/Sources/UILibrary/Extensions/ProfileEditor+UI.swift index e82d71f7..7333d3bc 100644 --- a/Library/Sources/UILibrary/Extensions/ProfileEditor+UI.swift +++ b/Library/Sources/UILibrary/Extensions/ProfileEditor+UI.swift @@ -23,18 +23,11 @@ // along with Passepartout. If not, see . // +import CommonLibrary import PassepartoutKit import SwiftUI extension ProfileEditor { - public func binding(forNameOf moduleId: UUID) -> Binding { - Binding { [weak self] in - self?.profile.name(forModuleWithId: moduleId) ?? "" - } set: { [weak self] in - self?.profile.setName($0, forModuleWithId: moduleId) - } - } - public subscript(module: T) -> Binding where T: ModuleBuilder { Binding { [weak self] in guard let foundModule = self?.module(withId: module.id) else { @@ -48,4 +41,12 @@ 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/Library/Sources/UILibrary/Strategy/ModuleViewFactory+Default.swift b/Library/Sources/UILibrary/Strategy/ModuleViewFactory+Default.swift index b41d7763..df19d309 100644 --- a/Library/Sources/UILibrary/Strategy/ModuleViewFactory+Default.swift +++ b/Library/Sources/UILibrary/Strategy/ModuleViewFactory+Default.swift @@ -36,12 +36,11 @@ public final class DefaultModuleViewFactory: ModuleViewFactory { } @ViewBuilder - public func view(with editor: ProfileEditor, preferences: ModulePreferences, moduleId: UUID) -> some View { + public func view(with editor: ProfileEditor, 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 1b46d1ae..227f4b17 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, preferences: ModulePreferences, moduleId: UUID) -> Content + func view(with editor: ProfileEditor, moduleId: UUID) -> Content } diff --git a/Library/Sources/UILibrary/Strategy/ModuleViewProviding.swift b/Library/Sources/UILibrary/Strategy/ModuleViewProviding.swift index bfc47208..9b71dda0 100644 --- a/Library/Sources/UILibrary/Strategy/ModuleViewProviding.swift +++ b/Library/Sources/UILibrary/Strategy/ModuleViewProviding.swift @@ -37,18 +37,14 @@ 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 ?? ModulePreferences(proxy: nil) self.impl = impl } } diff --git a/Library/Tests/UILibraryTests/ProfileEditorTests.swift b/Library/Tests/UILibraryTests/ProfileEditorTests.swift index cd7a6cc9..15ce4182 100644 --- a/Library/Tests/UILibraryTests/ProfileEditorTests.swift +++ b/Library/Tests/UILibraryTests/ProfileEditorTests.swift @@ -251,7 +251,7 @@ extension ProfileEditorTests { } .store(in: &subscriptions) - try await sut.save(to: manager) + try await sut.save(to: manager, preferencesManager: PreferencesManager()) await fulfillment(of: [exp]) } } diff --git a/Passepartout/Shared/AppContext+Shared.swift b/Passepartout/Shared/AppContext+Shared.swift index fd2beef1..66b0cf8e 100644 --- a/Passepartout/Shared/AppContext+Shared.swift +++ b/Passepartout/Shared/AppContext+Shared.swift @@ -39,7 +39,7 @@ import UITesting extension AppContext { static let shared: AppContext = { let iapManager: IAPManager = .sharedForApp - let processor = InAppProcessor.shared(iapManager) { + let processor = InAppProcessor.sharedImplementation(with: iapManager) { $0.localizedPreview } @@ -124,7 +124,7 @@ extension AppContext { migrationManager: migrationManager, profileManager: profileManager, providerManager: providerManager, - preferencesManager: .shared, + preferencesManager: .sharedForApp, registry: .shared, tunnel: tunnel, tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt diff --git a/Passepartout/Shared/Shared+App.swift b/Passepartout/Shared/Shared+App.swift index e3f2d024..5720906c 100644 --- a/Passepartout/Shared/Shared+App.swift +++ b/Passepartout/Shared/Shared+App.swift @@ -39,6 +39,10 @@ extension IAPManager { ) } +extension PreferencesManager { + static let sharedForApp = PreferencesManager.sharedImplementation(withCloudKit: true) +} + // MARK: - Dependencies private extension Dependencies.IAPManager { diff --git a/Passepartout/Shared/Shared+Tunnel.swift b/Passepartout/Shared/Shared+Tunnel.swift index 8aaaa6ed..563e6847 100644 --- a/Passepartout/Shared/Shared+Tunnel.swift +++ b/Passepartout/Shared/Shared+Tunnel.swift @@ -37,6 +37,10 @@ extension IAPManager { ) } +extension PreferencesManager { + static let sharedForTunnel = PreferencesManager.sharedImplementation(withCloudKit: false) +} + // MARK: - Dependencies private extension Dependencies.IAPManager { diff --git a/Passepartout/Shared/Shared.swift b/Passepartout/Shared/Shared.swift index 002789b4..8e2e1c5b 100644 --- a/Passepartout/Shared/Shared.swift +++ b/Passepartout/Shared/Shared.swift @@ -92,7 +92,7 @@ extension TunnelEnvironment where Self == AppGroupEnvironment { extension InAppProcessor { @MainActor - static func shared(_ iapManager: IAPManager, preview: @escaping (Profile) -> ProfilePreview) -> InAppProcessor { + static func sharedImplementation(with iapManager: IAPManager, preview: @escaping (Profile) -> ProfilePreview) -> InAppProcessor { InAppProcessor( iapManager: iapManager, title: { @@ -132,22 +132,24 @@ extension InAppProcessor { } extension PreferencesManager { - static let shared: PreferencesManager = { + + @MainActor + static func sharedImplementation(withCloudKit: Bool) -> PreferencesManager { let preferencesStore = CoreDataPersistentStore( logger: .default, containerName: Constants.shared.containers.preferences, baseURL: BundleConfiguration.urlForGroupDocuments, model: AppData.cdPreferencesModel, - cloudKitIdentifier: BundleConfiguration.mainString(for: .cloudKitPreferencesId), + cloudKitIdentifier: withCloudKit ? BundleConfiguration.mainString(for: .cloudKitPreferencesId) : nil, author: nil ) - let modulePreferencesRepository = AppData.cdModulePreferencesRepositoryV3(context: preferencesStore.context) - let providerPreferencesRepository = AppData.cdProviderPreferencesRepositoryV3(context: preferencesStore.context) return PreferencesManager( - modulesRepository: modulePreferencesRepository, - providersRepository: providerPreferencesRepository + modulesRepository: AppData.cdModulePreferencesRepositoryV3(context: preferencesStore.context), + providersFactory: { + try AppData.cdProviderPreferencesRepositoryV3(context: preferencesStore.context, providerId: $0) + } ) - }() + } } // MARK: - Logging diff --git a/Passepartout/Shared/Testing/AppContext+Testing.swift b/Passepartout/Shared/Testing/AppContext+Testing.swift index 1452a7ae..33f4bc29 100644 --- a/Passepartout/Shared/Testing/AppContext+Testing.swift +++ b/Passepartout/Shared/Testing/AppContext+Testing.swift @@ -40,7 +40,7 @@ extension AppContext { [] } ) - let processor = InAppProcessor.shared(iapManager) { + let processor = InAppProcessor.sharedImplementation(with: iapManager) { $0.localizedPreview } diff --git a/Passepartout/Tunnel/PacketTunnelProvider.swift b/Passepartout/Tunnel/PacketTunnelProvider.swift index 06441516..9a165977 100644 --- a/Passepartout/Tunnel/PacketTunnelProvider.swift +++ b/Passepartout/Tunnel/PacketTunnelProvider.swift @@ -41,10 +41,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { provider: self, decoder: Registry.sharedProtocolCoder, registry: .shared, - environment: .shared, - profileBlock: { - $0 - } + environment: .shared ) guard let fwd else { fatalError("NEPTPForwarder nil without throwing error?")