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
This commit is contained in:
Davide 2024-12-08 16:05:23 +01:00 committed by GitHub
parent 14847b2de5
commit a4ebea1f95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 279 additions and 224 deletions

View File

@ -41,7 +41,7 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source", "location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
"state" : { "state" : {
"revision" : "d753c05f36b789fe413aeccbb543fb8c383ddc2b" "revision" : "406712a60faf8208a15c4ffaf286b1c71df7c6d2"
} }
}, },
{ {

View File

@ -65,7 +65,7 @@ let package = Package(
], ],
dependencies: [ dependencies: [
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.12.0"), // .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(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", from: "0.9.1"),
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"), // .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"),

View File

@ -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 <http://www.gnu.org/licenses/>.
//
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 {
}
}

View File

@ -30,15 +30,11 @@ import Foundation
import PassepartoutKit import PassepartoutKit
extension AppData { extension AppData {
@MainActor
public static func cdModulePreferencesRepositoryV3(context: NSManagedObjectContext) -> ModulePreferencesRepository { public static func cdModulePreferencesRepositoryV3(context: NSManagedObjectContext) -> ModulePreferencesRepository {
CDModulePreferencesRepositoryV3(context: context) CDModulePreferencesRepositoryV3(context: context)
} }
} }
// MARK: - Repository
private final class CDModulePreferencesRepositoryV3: ModulePreferencesRepository { private final class CDModulePreferencesRepositoryV3: ModulePreferencesRepository {
private nonisolated let context: NSManagedObjectContext private nonisolated let context: NSManagedObjectContext
@ -46,36 +42,49 @@ private final class CDModulePreferencesRepositoryV3: ModulePreferencesRepository
self.context = context self.context = context
} }
func modulePreferencesProxy(in moduleId: UUID) throws -> ModulePreferencesProxy { func preferences(for moduleIds: [UUID]) throws -> [UUID: ModulePreferences] {
let entity = try context.performAndWait { try context.performAndWait {
let request = CDModulePreferencesV3.fetchRequest() 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 { do {
let entity = try request.execute().first ?? CDModulePreferencesV3(context: context) let preferences = try mapper.preferences(from: $1)
entity.uuid = moduleId $0[moduleId] = preferences
return entity
} catch { } catch {
pp_log(.app, .error, "Unable to load preferences for module \(moduleId): \(error)") pp_log(.app, .error, "Unable to load preferences for module \(moduleId): \(error)")
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 { 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 { guard context.hasChanges else {
return return
} }
@ -86,4 +95,9 @@ private final class CDModulePreferencesProxy: ModulePreferencesProxy {
throw error throw error
} }
} }
}
func rollback() {
context.rollback()
}
} }

View File

@ -30,24 +30,20 @@ import Foundation
import PassepartoutKit import PassepartoutKit
extension AppData { extension AppData {
public static func cdProviderPreferencesRepositoryV3(context: NSManagedObjectContext, providerId: ProviderID) throws -> ProviderPreferencesRepository {
@MainActor try CDProviderPreferencesRepositoryV3(context: context, providerId: providerId)
public static func cdProviderPreferencesRepositoryV3(context: NSManagedObjectContext) -> ProviderPreferencesRepository {
CDProviderPreferencesRepositoryV3(context: context)
} }
} }
// MARK: - Repository
private final class CDProviderPreferencesRepositoryV3: ProviderPreferencesRepository { private final class CDProviderPreferencesRepositoryV3: ProviderPreferencesRepository {
private nonisolated let context: NSManagedObjectContext private nonisolated let context: NSManagedObjectContext
init(context: NSManagedObjectContext) { private let entity: CDProviderPreferencesV3
self.context = context
}
func providerPreferencesProxy(in providerId: ProviderID) throws -> ProviderPreferencesProxy { init(context: NSManagedObjectContext, providerId: ProviderID) throws {
let entity = try context.performAndWait { self.context = context
entity = try context.performAndWait {
let request = CDProviderPreferencesV3.fetchRequest() let request = CDProviderPreferencesV3.fetchRequest()
request.predicate = NSPredicate(format: "providerId == %@", providerId.rawValue) request.predicate = NSPredicate(format: "providerId == %@", providerId.rawValue)
do { do {
@ -59,20 +55,6 @@ private final class CDProviderPreferencesRepositoryV3: ProviderPreferencesReposi
throw error 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<String> { var favoriteServers: Set<String> {

View File

@ -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 context: NSManagedObjectContext
private nonisolated let providersSubject: CurrentValueSubject<[Provider], Never> private nonisolated let providersSubject: CurrentValueSubject<[Provider], Never>

View File

@ -34,6 +34,9 @@ public struct AppCoordinator: View, AppCoordinatorConforming, SizeClassProviding
@EnvironmentObject @EnvironmentObject
public var iapManager: IAPManager public var iapManager: IAPManager
@EnvironmentObject
public var preferencesManager: PreferencesManager
@Environment(\.isUITesting) @Environment(\.isUITesting)
private var isUITesting private var isUITesting
@ -117,12 +120,7 @@ extension AppCoordinator {
isImporting: $isImporting, isImporting: $isImporting,
errorHandler: errorHandler, errorHandler: errorHandler,
flow: .init( flow: .init(
onEditProfile: { onEditProfile: onEditProfile,
guard let profile = profileManager.profile(withId: $0.id) else {
return
}
enterDetail(of: profile.editable(), initialModuleId: nil)
},
onMigrateProfiles: { onMigrateProfiles: {
modalRoute = .migrateProfiles modalRoute = .migrateProfiles
}, },
@ -168,7 +166,7 @@ extension AppCoordinator {
onMigrateProfiles: { onMigrateProfiles: {
present(.migrateProfiles) present(.migrateProfiles)
}, },
onNewProfile: enterDetail onNewProfile: onNewProfile
) )
} }
@ -232,7 +230,11 @@ extension AppCoordinator {
extension AppCoordinator { extension AppCoordinator {
public func onInteractiveLogin(_ profile: Profile, _ onComplete: @escaping InteractiveManager.CompletionBlock) { public func onInteractiveLogin(_ profile: Profile, _ onComplete: @escaping InteractiveManager.CompletionBlock) {
pp_log(.app, .info, "Present interactive login") 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) { 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() profilePath = NavigationPath()
let isShared = profileManager.isRemotelyShared(profileWithId: profile.id) let isShared = profileManager.isRemotelyShared(profileWithId: profile.id)
profileEditor.editProfile(profile, isShared: isShared) profileEditor.load(profile, isShared: isShared, preferencesManager: preferencesManager)
present(.editProfile(initialModuleId)) present(.editProfile(initialModuleId))
} }
} }

View File

@ -242,7 +242,6 @@ private extension OnDemandView {
module: $0, module: $0,
parameters: .init( parameters: .init(
editor: $1, editor: $1,
preferences: nil,
impl: nil impl: nil
), ),
observer: MockWifi() observer: MockWifi()

View File

@ -28,19 +28,12 @@ import PassepartoutKit
import SwiftUI import SwiftUI
struct ModuleDetailView: View { struct ModuleDetailView: View {
@EnvironmentObject
private var preferencesManager: PreferencesManager
let profileEditor: ProfileEditor let profileEditor: ProfileEditor
let moduleId: UUID? let moduleId: UUID?
let moduleViewFactory: any ModuleViewFactory let moduleViewFactory: any ModuleViewFactory
@StateObject
private var modulePreferences = ModulePreferences(proxy: nil)
var body: some View { var body: some View {
debugChanges() debugChanges()
return Group { return Group {
@ -50,16 +43,6 @@ struct ModuleDetailView: View {
emptyView 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 { func editorView(forModuleWithId moduleId: UUID) -> some View {
AnyView(moduleViewFactory.view( AnyView(moduleViewFactory.view(
with: profileEditor, with: profileEditor,
preferences: modulePreferences,
moduleId: moduleId moduleId: moduleId
)) ))
} }

View File

@ -43,6 +43,9 @@ struct ProfileCoordinator: View {
@EnvironmentObject @EnvironmentObject
private var iapManager: IAPManager private var iapManager: IAPManager
@EnvironmentObject
private var preferencesManager: PreferencesManager
let profileManager: ProfileManager let profileManager: ProfileManager
let profileEditor: ProfileEditor let profileEditor: ProfileEditor
@ -133,7 +136,10 @@ private extension ProfileCoordinator {
// standard: always save, warn if purchase required // standard: always save, warn if purchase required
func onCommitEditingStandard() async throws { func onCommitEditingStandard() async throws {
let savedProfile = try await profileEditor.save(to: profileManager) let savedProfile = try await profileEditor.save(
to: profileManager,
preferencesManager: preferencesManager
)
do { do {
try iapManager.verify(savedProfile) try iapManager.verify(savedProfile)
} catch AppError.ineligibleProfile(let requiredFeatures) { } catch AppError.ineligibleProfile(let requiredFeatures) {
@ -151,11 +157,15 @@ private extension ProfileCoordinator {
paywallReason = .init(requiredFeatures) paywallReason = .init(requiredFeatures)
return return
} }
try await profileEditor.save(to: profileManager) try await profileEditor.save(
to: profileManager,
preferencesManager: preferencesManager
)
onDismiss() onDismiss()
} }
func onCancelEditing() { func onCancelEditing() {
profileEditor.discard()
onDismiss() onDismiss()
} }
} }

View File

@ -68,7 +68,7 @@ struct VPNProviderServerView<Configuration>: View where Configuration: Identifia
private var onlyShowsFavorites = false private var onlyShowsFavorites = false
@StateObject @StateObject
private var providerPreferences = ProviderPreferences(proxy: nil) private var providerPreferences = ProviderPreferences()
@StateObject @StateObject
private var filtersViewModel = VPNFiltersView.Model() private var filtersViewModel = VPNFiltersView.Model()
@ -159,7 +159,7 @@ private extension VPNProviderServerView {
private extension VPNProviderServerView { private extension VPNProviderServerView {
func loadInitialServers() async { func loadInitialServers() async {
do { do {
providerPreferences.proxy = try preferencesManager.providerPreferencesProxy(in: providerId) providerPreferences.repository = try preferencesManager.preferencesRepository(forProviderWithId: providerId)
} catch { } catch {
pp_log(.app, .error, "Unable to load preferences for provider \(providerId): \(error)") pp_log(.app, .error, "Unable to load preferences for provider \(providerId): \(error)")
} }

View File

@ -34,6 +34,9 @@ public struct AppCoordinator: View, AppCoordinatorConforming {
@EnvironmentObject @EnvironmentObject
public var iapManager: IAPManager public var iapManager: IAPManager
@EnvironmentObject
private var preferencesManager: PreferencesManager
private let profileManager: ProfileManager private let profileManager: ProfileManager
public let tunnel: ExtendedTunnel public let tunnel: ExtendedTunnel
@ -136,7 +139,11 @@ private extension AppCoordinator {
extension AppCoordinator { extension AppCoordinator {
public func onInteractiveLogin(_ profile: Profile, _ onComplete: @escaping InteractiveManager.CompletionBlock) { public func onInteractiveLogin(_ profile: Profile, _ onComplete: @escaping InteractiveManager.CompletionBlock) {
pp_log(.app, .info, "Present interactive login") 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) { public func onProviderEntityRequired(_ profile: Profile, force: Bool) {

View File

@ -27,51 +27,66 @@ import CommonUtils
import Foundation import Foundation
import PassepartoutKit import PassepartoutKit
@MainActor public final class PreferencesManager: ObservableObject, Sendable {
public final class PreferencesManager: ObservableObject {
private let modulesRepository: ModulePreferencesRepository private let modulesRepository: ModulePreferencesRepository
private let providersRepository: ProviderPreferencesRepository private let providersFactory: @Sendable (ProviderID) throws -> ProviderPreferencesRepository
public init( public init(
modulesRepository: ModulePreferencesRepository? = nil, modulesRepository: ModulePreferencesRepository? = nil,
providersRepository: ProviderPreferencesRepository? = nil providersFactory: (@Sendable (ProviderID) throws -> ProviderPreferencesRepository)? = nil
) { ) {
self.modulesRepository = modulesRepository ?? DummyModulePreferencesRepository() 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 { public func preferences(forProfile editableProfile: EditableProfile) throws -> [UUID: ModulePreferences] {
try modulesRepository.modulePreferencesProxy(in: moduleId) try preferences(forModulesWithIds: editableProfile.modules.map(\.id))
} }
public func providerPreferencesProxy(in providerId: ProviderID) throws -> ProviderPreferencesProxy { public func savePreferences(_ preferences: [UUID: ModulePreferences]) throws {
try providersRepository.providerPreferencesProxy(in: providerId) 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 // MARK: - Dummy
private final class DummyModulePreferencesRepository: ModulePreferencesRepository { private final class DummyModulePreferencesRepository: ModulePreferencesRepository {
private final class Proxy: ModulePreferencesProxy { func preferences(for moduleIds: [UUID]) throws -> [UUID: ModulePreferences] {
func save() throws { [:]
}
} }
func modulePreferencesProxy(in moduleId: UUID) throws -> ModulePreferencesProxy { func set(_ preferences: [UUID: ModulePreferences]) throws {
Proxy()
} }
} }
private final class DummyProviderPreferencesRepository: ProviderPreferencesRepository { private final class DummyProviderPreferencesRepository: ProviderPreferencesRepository {
private final class Proxy: ProviderPreferencesProxy {
var favoriteServers: Set<String> = [] var favoriteServers: Set<String> = []
func save() throws { func save() throws {
} }
}
func providerPreferencesProxy(in providerId: ProviderID) throws -> ProviderPreferencesProxy {
Proxy()
}
} }

View File

@ -27,35 +27,27 @@ import Foundation
import PassepartoutKit import PassepartoutKit
@MainActor @MainActor
public final class ProviderPreferences: ObservableObject { public final class ProviderPreferences: ObservableObject, ProviderPreferencesRepository {
public var proxy: ProviderPreferencesProxy? { public var repository: ProviderPreferencesRepository? {
didSet { didSet {
objectWillChange.send() objectWillChange.send()
} }
} }
public init(proxy: ProviderPreferencesProxy?) { public init() {
self.proxy = proxy
} }
public var favoriteServers: Set<String> { public var favoriteServers: Set<String> {
get { get {
proxy?.favoriteServers ?? [] repository?.favoriteServers ?? []
} }
set { set {
objectWillChange.send() objectWillChange.send()
proxy?.favoriteServers = newValue repository?.favoriteServers = newValue
} }
} }
public func save() throws { public func save() throws {
try proxy?.save() try repository?.save()
} }
} }
@MainActor
public protocol ProviderPreferencesProxy {
var favoriteServers: Set<String> { get set }
func save() throws
}

View File

@ -35,8 +35,6 @@ public struct EditableProfile: MutableProfileType {
public var activeModulesIds: Set<UUID> public var activeModulesIds: Set<UUID>
public var modulesMetadata: [UUID: ModuleMetadata]?
public var userInfo: AnyHashable? public var userInfo: AnyHashable?
public init( public init(
@ -44,14 +42,12 @@ public struct EditableProfile: MutableProfileType {
name: String = "", name: String = "",
modules: [any ModuleBuilder] = [], modules: [any ModuleBuilder] = [],
activeModulesIds: Set<UUID> = [], activeModulesIds: Set<UUID> = [],
modulesMetadata: [UUID: ModuleMetadata]? = nil,
userInfo: AnyHashable? = nil userInfo: AnyHashable? = nil
) { ) {
self.id = id self.id = id
self.name = name self.name = name
self.modules = modules self.modules = modules
self.activeModulesIds = activeModulesIds self.activeModulesIds = activeModulesIds
self.modulesMetadata = modulesMetadata
self.userInfo = userInfo self.userInfo = userInfo
} }
@ -71,16 +67,6 @@ public struct EditableProfile: MutableProfileType {
throw AppError.emptyProfileName throw AppError.emptyProfileName
} }
builder.name = trimmedName 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 builder.userInfo = userInfo
return builder return builder
@ -105,7 +91,6 @@ extension Profile {
name: name, name: name,
modules: modulesBuilders(), modules: modulesBuilders(),
activeModulesIds: activeModulesIds, activeModulesIds: activeModulesIds,
modulesMetadata: modulesMetadata,
userInfo: userInfo userInfo: userInfo
) )
} }

View File

@ -26,24 +26,7 @@
import Foundation import Foundation
import PassepartoutKit import PassepartoutKit
@MainActor public struct ModulePreferences: Sendable {
public final class ModulePreferences: ObservableObject { public init() {
public var proxy: ModulePreferencesProxy? {
didSet {
objectWillChange.send()
}
}
public init(proxy: ModulePreferencesProxy?) {
self.proxy = proxy
}
public func save() throws {
try proxy?.save()
} }
} }
@MainActor
public protocol ModulePreferencesProxy {
func save() throws
}

View File

@ -24,8 +24,10 @@
// //
import Foundation import Foundation
import PassepartoutKit
@MainActor public protocol ModulePreferencesRepository: Sendable {
public protocol ModulePreferencesRepository { func preferences(for moduleIds: [UUID]) throws -> [UUID: ModulePreferences]
func modulePreferencesProxy(in moduleId: UUID) throws -> ModulePreferencesProxy
func set(_ preferences: [UUID: ModulePreferences]) throws
} }

View File

@ -26,7 +26,8 @@
import Foundation import Foundation
import PassepartoutKit import PassepartoutKit
@MainActor
public protocol ProviderPreferencesRepository { public protocol ProviderPreferencesRepository {
func providerPreferencesProxy(in providerId: ProviderID) throws -> ProviderPreferencesProxy var favoriteServers: Set<String> { get set }
func save() throws
} }

View File

@ -28,13 +28,13 @@ import Combine
import CoreData import CoreData
import Foundation import Foundation
public protocol CoreDataPersistentStoreLogger { public protocol CoreDataPersistentStoreLogger: Sendable {
func debug(_ msg: String) func debug(_ msg: String)
func warning(_ msg: String) func warning(_ msg: String)
} }
public final class CoreDataPersistentStore { public final class CoreDataPersistentStore: Sendable {
private let logger: CoreDataPersistentStoreLogger? private let logger: CoreDataPersistentStoreLogger?
private let container: NSPersistentContainer private let container: NSPersistentContainer

View File

@ -41,8 +41,9 @@ public final class InteractiveManager: ObservableObject {
public init() { public init() {
} }
public func present(with profile: Profile, onComplete: CompletionBlock?) { public func present(with profile: Profile, preferencesManager: PreferencesManager, onComplete: CompletionBlock?) {
editor = ProfileEditor(profile: profile) editor = ProfileEditor()
editor.load(profile.editable(), isShared: false, preferencesManager: preferencesManager)
self.onComplete = onComplete self.onComplete = onComplete
isPresented = true isPresented = true
} }

View File

@ -37,30 +37,31 @@ public final class ProfileEditor: ObservableObject {
@Published @Published
public var isShared: Bool public var isShared: Bool
@Published
public var preferences: [UUID: ModulePreferences]
private(set) var removedModules: [UUID: any ModuleBuilder] private(set) var removedModules: [UUID: any ModuleBuilder]
public convenience init() { public convenience init() {
self.init(modules: []) 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]) { public init(modules: [any ModuleBuilder]) {
editableProfile = EditableProfile( editableProfile = EditableProfile(
modules: modules, modules: modules,
activeModulesIds: Set(modules.map(\.id)) activeModulesIds: Set(modules.map(\.id))
) )
isShared = false isShared = false
removedModules = [:] preferences = [:]
}
public init(profile: Profile) {
editableProfile = profile.editable()
isShared = false
removedModules = [:]
}
public func editProfile(_ profile: EditableProfile, isShared: Bool) {
editableProfile = profile
self.isShared = isShared
removedModules = [:] removedModules = [:]
} }
} }
@ -198,21 +199,47 @@ extension ProfileEditor {
} }
} }
// MARK: - Saving // MARK: - Load/Save
extension ProfileEditor { 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 @discardableResult
public func save(to profileManager: ProfileManager) async throws -> Profile { public func save(
to profileManager: ProfileManager,
preferencesManager: PreferencesManager
) async throws -> Profile {
do { do {
let newProfile = try build() let newProfile = try build()
try await profileManager.save(newProfile, isLocal: true, remotelyShared: isShared) 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 return newProfile
} catch { } catch {
pp_log(.app, .fault, "Unable to save edited profile: \(error)") pp_log(.app, .fault, "Unable to save edited profile: \(error)")
throw error throw error
} }
} }
public func discard() {
}
} }
// MARK: - Testing // MARK: - Testing

View File

@ -33,7 +33,6 @@ extension ModuleBuilder where Self: ModuleViewProviding {
NavigationStack { NavigationStack {
moduleView(with: .init( moduleView(with: .init(
editor: ProfileEditor(modules: [self]), editor: ProfileEditor(modules: [self]),
preferences: nil,
impl: nil impl: nil
)) ))
.navigationTitle(title) .navigationTitle(title)

View File

@ -23,18 +23,11 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>. // along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
// //
import CommonLibrary
import PassepartoutKit import PassepartoutKit
import SwiftUI import SwiftUI
extension ProfileEditor { extension ProfileEditor {
public func binding(forNameOf moduleId: UUID) -> Binding<String> {
Binding { [weak self] in
self?.profile.name(forModuleWithId: moduleId) ?? ""
} set: { [weak self] in
self?.profile.setName($0, forModuleWithId: moduleId)
}
}
public subscript<T>(module: T) -> Binding<T> where T: ModuleBuilder { public subscript<T>(module: T) -> Binding<T> where T: ModuleBuilder {
Binding { [weak self] in Binding { [weak self] in
guard let foundModule = self?.module(withId: module.id) else { guard let foundModule = self?.module(withId: module.id) else {
@ -48,4 +41,12 @@ extension ProfileEditor {
self?.saveModule($0, activating: false) self?.saveModule($0, activating: false)
} }
} }
public func binding(forPreferencesOf moduleId: UUID) -> Binding<ModulePreferences> {
Binding { [weak self] in
self?.preferences[moduleId] ?? ModulePreferences()
} set: { [weak self] in
self?.preferences[moduleId] = $0
}
}
} }

View File

@ -36,12 +36,11 @@ public final class DefaultModuleViewFactory: ModuleViewFactory {
} }
@ViewBuilder @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) let result = editor.moduleViewProvider(withId: moduleId, registry: registry)
if let result { if let result {
AnyView(result.provider.moduleView(with: .init( AnyView(result.provider.moduleView(with: .init(
editor: editor, editor: editor,
preferences: preferences,
impl: result.impl impl: result.impl
))) )))
.navigationTitle(result.title) .navigationTitle(result.title)

View File

@ -31,5 +31,5 @@ public protocol ModuleViewFactory: AnyObject {
associatedtype Content: View associatedtype Content: View
@MainActor @MainActor
func view(with editor: ProfileEditor, preferences: ModulePreferences, moduleId: UUID) -> Content func view(with editor: ProfileEditor, moduleId: UUID) -> Content
} }

View File

@ -37,18 +37,14 @@ public protocol ModuleViewProviding {
public struct ModuleViewParameters { public struct ModuleViewParameters {
public let editor: ProfileEditor public let editor: ProfileEditor
public let preferences: ModulePreferences
public let impl: (any ModuleImplementation)? public let impl: (any ModuleImplementation)?
@MainActor @MainActor
public init( public init(
editor: ProfileEditor, editor: ProfileEditor,
preferences: ModulePreferences?,
impl: (any ModuleImplementation)? impl: (any ModuleImplementation)?
) { ) {
self.editor = editor self.editor = editor
self.preferences = preferences ?? ModulePreferences(proxy: nil)
self.impl = impl self.impl = impl
} }
} }

View File

@ -251,7 +251,7 @@ extension ProfileEditorTests {
} }
.store(in: &subscriptions) .store(in: &subscriptions)
try await sut.save(to: manager) try await sut.save(to: manager, preferencesManager: PreferencesManager())
await fulfillment(of: [exp]) await fulfillment(of: [exp])
} }
} }

View File

@ -39,7 +39,7 @@ import UITesting
extension AppContext { extension AppContext {
static let shared: AppContext = { static let shared: AppContext = {
let iapManager: IAPManager = .sharedForApp let iapManager: IAPManager = .sharedForApp
let processor = InAppProcessor.shared(iapManager) { let processor = InAppProcessor.sharedImplementation(with: iapManager) {
$0.localizedPreview $0.localizedPreview
} }
@ -124,7 +124,7 @@ extension AppContext {
migrationManager: migrationManager, migrationManager: migrationManager,
profileManager: profileManager, profileManager: profileManager,
providerManager: providerManager, providerManager: providerManager,
preferencesManager: .shared, preferencesManager: .sharedForApp,
registry: .shared, registry: .shared,
tunnel: tunnel, tunnel: tunnel,
tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt

View File

@ -39,6 +39,10 @@ extension IAPManager {
) )
} }
extension PreferencesManager {
static let sharedForApp = PreferencesManager.sharedImplementation(withCloudKit: true)
}
// MARK: - Dependencies // MARK: - Dependencies
private extension Dependencies.IAPManager { private extension Dependencies.IAPManager {

View File

@ -37,6 +37,10 @@ extension IAPManager {
) )
} }
extension PreferencesManager {
static let sharedForTunnel = PreferencesManager.sharedImplementation(withCloudKit: false)
}
// MARK: - Dependencies // MARK: - Dependencies
private extension Dependencies.IAPManager { private extension Dependencies.IAPManager {

View File

@ -92,7 +92,7 @@ extension TunnelEnvironment where Self == AppGroupEnvironment {
extension InAppProcessor { extension InAppProcessor {
@MainActor @MainActor
static func shared(_ iapManager: IAPManager, preview: @escaping (Profile) -> ProfilePreview) -> InAppProcessor { static func sharedImplementation(with iapManager: IAPManager, preview: @escaping (Profile) -> ProfilePreview) -> InAppProcessor {
InAppProcessor( InAppProcessor(
iapManager: iapManager, iapManager: iapManager,
title: { title: {
@ -132,22 +132,24 @@ extension InAppProcessor {
} }
extension PreferencesManager { extension PreferencesManager {
static let shared: PreferencesManager = {
@MainActor
static func sharedImplementation(withCloudKit: Bool) -> PreferencesManager {
let preferencesStore = CoreDataPersistentStore( let preferencesStore = CoreDataPersistentStore(
logger: .default, logger: .default,
containerName: Constants.shared.containers.preferences, containerName: Constants.shared.containers.preferences,
baseURL: BundleConfiguration.urlForGroupDocuments, baseURL: BundleConfiguration.urlForGroupDocuments,
model: AppData.cdPreferencesModel, model: AppData.cdPreferencesModel,
cloudKitIdentifier: BundleConfiguration.mainString(for: .cloudKitPreferencesId), cloudKitIdentifier: withCloudKit ? BundleConfiguration.mainString(for: .cloudKitPreferencesId) : nil,
author: nil author: nil
) )
let modulePreferencesRepository = AppData.cdModulePreferencesRepositoryV3(context: preferencesStore.context)
let providerPreferencesRepository = AppData.cdProviderPreferencesRepositoryV3(context: preferencesStore.context)
return PreferencesManager( return PreferencesManager(
modulesRepository: modulePreferencesRepository, modulesRepository: AppData.cdModulePreferencesRepositoryV3(context: preferencesStore.context),
providersRepository: providerPreferencesRepository providersFactory: {
try AppData.cdProviderPreferencesRepositoryV3(context: preferencesStore.context, providerId: $0)
}
) )
}() }
} }
// MARK: - Logging // MARK: - Logging

View File

@ -40,7 +40,7 @@ extension AppContext {
[] []
} }
) )
let processor = InAppProcessor.shared(iapManager) { let processor = InAppProcessor.sharedImplementation(with: iapManager) {
$0.localizedPreview $0.localizedPreview
} }

View File

@ -41,10 +41,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
provider: self, provider: self,
decoder: Registry.sharedProtocolCoder, decoder: Registry.sharedProtocolCoder,
registry: .shared, registry: .shared,
environment: .shared, environment: .shared
profileBlock: {
$0
}
) )
guard let fwd else { guard let fwd else {
fatalError("NEPTPForwarder nil without throwing error?") fatalError("NEPTPForwarder nil without throwing error?")