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
This commit is contained in:
Davide 2024-12-10 11:18:52 +01:00 committed by GitHub
parent 912f62b29e
commit aeec943c58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 340 additions and 420 deletions

View File

@ -33,6 +33,5 @@ final class CDExcludedEndpoint: NSManagedObject {
} }
@NSManaged var endpoint: String? @NSManaged var endpoint: String?
@NSManaged var modulePreferences: CDModulePreferencesV3?
@NSManaged var providerPreferences: CDProviderPreferencesV3? @NSManaged var providerPreferences: CDProviderPreferencesV3?
} }

View File

@ -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 <http://www.gnu.org/licenses/>.
//
import CoreData
import Foundation
@objc(CDModulePreferencesV3)
final class CDModulePreferencesV3: NSManagedObject {
@nonobjc static func fetchRequest() -> NSFetchRequest<CDModulePreferencesV3> {
NSFetchRequest<CDModulePreferencesV3>(entityName: "CDModulePreferencesV3")
}
@NSManaged var uuid: UUID?
@NSManaged var lastUpdate: Date?
@NSManaged var excludedEndpoints: Set<CDExcludedEndpoint>?
}

View File

@ -2,14 +2,8 @@
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23507" systemVersion="23H222" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23507" systemVersion="23H222" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
<entity name="CDExcludedEndpoint" representedClassName="CDExcludedEndpoint" syncable="YES"> <entity name="CDExcludedEndpoint" representedClassName="CDExcludedEndpoint" syncable="YES">
<attribute name="endpoint" optional="YES" attributeType="String"/> <attribute name="endpoint" optional="YES" attributeType="String"/>
<relationship name="modulePreferences" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CDModulePreferencesV3" inverseName="excludedEndpoints" inverseEntity="CDModulePreferencesV3"/>
<relationship name="providerPreferences" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CDProviderPreferencesV3" inverseName="excludedEndpoints" inverseEntity="CDProviderPreferencesV3"/> <relationship name="providerPreferences" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CDProviderPreferencesV3" inverseName="excludedEndpoints" inverseEntity="CDProviderPreferencesV3"/>
</entity> </entity>
<entity name="CDModulePreferencesV3" representedClassName="CDModulePreferencesV3" syncable="YES">
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="uuid" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<relationship name="excludedEndpoints" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="CDExcludedEndpoint" inverseName="modulePreferences" inverseEntity="CDExcludedEndpoint"/>
</entity>
<entity name="CDProviderPreferencesV3" representedClassName="CDProviderPreferencesV3" syncable="YES"> <entity name="CDProviderPreferencesV3" representedClassName="CDProviderPreferencesV3" syncable="YES">
<attribute name="favoriteServerIds" optional="YES" attributeType="Binary"/> <attribute name="favoriteServerIds" optional="YES" attributeType="Binary"/>
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>

View File

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

View File

@ -51,9 +51,6 @@ struct OpenVPNView: View, ModuleDraftEditing {
@State @State
private var paywallReason: PaywallReason? private var paywallReason: PaywallReason?
@StateObject
private var preferences = ModulePreferences()
@StateObject @StateObject
private var providerPreferences = ProviderPreferences() private var providerPreferences = ProviderPreferences()
@ -88,13 +85,6 @@ struct OpenVPNView: View, ModuleDraftEditing {
.navigationDestination(for: Subroute.self, destination: destination) .navigationDestination(for: Subroute.self, destination: destination)
.themeAnimation(on: draft.wrappedValue.providerId, category: .modules) .themeAnimation(on: draft.wrappedValue.providerId, category: .modules)
.withErrorHandler(errorHandler) .withErrorHandler(errorHandler)
.onLoad {
editor.loadPreferences(
preferences,
from: preferencesManager,
forModuleWithId: module.id
)
}
} }
} }
@ -213,7 +203,7 @@ private extension OpenVPNView {
if draft.wrappedValue.providerSelection != nil { if draft.wrappedValue.providerSelection != nil {
return providerPreferences.excludedEndpoints() return providerPreferences.excludedEndpoints()
} else { } else {
return preferences.excludedEndpoints() return editor.excludedEndpoints(for: module.id)
} }
} }

View File

@ -28,17 +28,11 @@ import Foundation
import PassepartoutKit import PassepartoutKit
public final class PreferencesManager: ObservableObject, Sendable { public final class PreferencesManager: ObservableObject, Sendable {
private let modulesFactory: @Sendable (UUID) throws -> ModulePreferencesRepository
private let providersFactory: @Sendable (ProviderID) throws -> ProviderPreferencesRepository private let providersFactory: @Sendable (ProviderID) throws -> ProviderPreferencesRepository
public init( public init(
modulesFactory: (@Sendable (UUID) throws -> ModulePreferencesRepository)? = nil,
providersFactory: (@Sendable (ProviderID) throws -> ProviderPreferencesRepository)? = nil providersFactory: (@Sendable (ProviderID) throws -> ProviderPreferencesRepository)? = nil
) { ) {
self.modulesFactory = modulesFactory ?? { _ in
DummyModulePreferencesRepository()
}
self.providersFactory = providersFactory ?? { _ in self.providersFactory = providersFactory ?? { _ in
DummyProviderPreferencesRepository() DummyProviderPreferencesRepository()
} }
@ -46,10 +40,6 @@ public final class PreferencesManager: ObservableObject, Sendable {
} }
extension PreferencesManager { extension PreferencesManager {
public func preferencesRepository(forModuleWithId moduleId: UUID) throws -> ModulePreferencesRepository {
try modulesFactory(moduleId)
}
public func preferencesRepository(forProviderWithId providerId: ProviderID) throws -> ProviderPreferencesRepository { public func preferencesRepository(forProviderWithId providerId: ProviderID) throws -> ProviderPreferencesRepository {
try providersFactory(providerId) try providersFactory(providerId)
} }
@ -57,12 +47,6 @@ extension PreferencesManager {
@MainActor @MainActor
extension PreferencesManager { 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 { public func preferences(forProviderWithId providerId: ProviderID) throws -> ProviderPreferences {
let object = ProviderPreferences() let object = ProviderPreferences()
object.repository = try providersFactory(providerId) object.repository = try providersFactory(providerId)
@ -72,24 +56,6 @@ extension PreferencesManager {
// MARK: - Dummy // 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 { private final class DummyProviderPreferencesRepository: ProviderPreferencesRepository {
var favoriteServers: Set<String> = [] var favoriteServers: Set<String> = []

View File

@ -440,15 +440,15 @@ private extension ProfileManager {
pp_log(.App.profiles, .info, "Start importing remote profiles: \(profiles.map(\.id))") 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") assert(profiles.count == Set(profiles.map(\.id)).count, "Remote repository must not have duplicates")
pp_log(.App.profiles, .debug, "Local attributes:") pp_log(.App.profiles, .debug, "Local fingerprints:")
let localAttributes: [Profile.ID: ProfileAttributes] = allProfiles.values.reduce(into: [:]) { let localFingerprints: [Profile.ID: UUID] = allProfiles.values.reduce(into: [:]) {
$0[$1.id] = $1.attributes $0[$1.id] = $1.attributes.fingerprint
pp_log(.App.profiles, .debug, "\t\($1.id) = \($1.attributes)") pp_log(.App.profiles, .debug, "\t\($1.id) = \($1.attributes.fingerprint.debugDescription)")
} }
pp_log(.App.profiles, .debug, "Remote attributes:") pp_log(.App.profiles, .debug, "Remote fingerprints:")
let remoteAttributes: [Profile.ID: ProfileAttributes] = profiles.reduce(into: [:]) { let remoteFingerprints: [Profile.ID: UUID] = profiles.reduce(into: [:]) {
$0[$1.id] = $1.attributes $0[$1.id] = $1.attributes.fingerprint
pp_log(.App.profiles, .debug, "\t\($1.id) = \($1.attributes)") pp_log(.App.profiles, .debug, "\t\($1.id) = \($1.attributes.fingerprint.debugDescription)")
} }
let remotelyDeletedIds = Set(allProfiles.keys).subtracting(Set(allRemoteProfiles.keys)) let remotelyDeletedIds = Set(allProfiles.keys).subtracting(Set(allRemoteProfiles.keys))
@ -473,8 +473,8 @@ private extension ProfileManager {
idsToRemove.append(remoteProfile.id) idsToRemove.append(remoteProfile.id)
continue continue
} }
if let localFingerprint = localAttributes[remoteProfile.id]?.fingerprint { if let localFingerprint = localFingerprints[remoteProfile.id] {
guard let remoteFingerprint = remoteAttributes[remoteProfile.id]?.fingerprint, guard let remoteFingerprint = remoteFingerprints[remoteProfile.id],
remoteFingerprint != localFingerprint else { remoteFingerprint != localFingerprint else {
pp_log(.App.profiles, .info, "Skip re-importing local profile \(remoteProfile.id)") pp_log(.App.profiles, .info, "Skip re-importing local profile \(remoteProfile.id)")
continue continue

View File

@ -73,17 +73,6 @@ public struct EditableProfile: MutableProfileType {
} }
} }
extension EditableProfile {
public var attributes: ProfileAttributes {
get {
userInfo() ?? ProfileAttributes()
}
set {
setUserInfo(newValue)
}
}
}
extension Profile { extension Profile {
public func editable() -> EditableProfile { public func editable() -> EditableProfile {
EditableProfile( EditableProfile(
@ -112,6 +101,8 @@ extension Module {
} }
} }
// MARK: -
private extension EditableProfile { private extension EditableProfile {
var activeConnectionModule: (any ModuleBuilder)? { var activeConnectionModule: (any ModuleBuilder)? {
modules.first { modules.first {

View File

@ -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 <http://www.gnu.org/licenses/>.
//
import CommonUtils
import Foundation
import PassepartoutKit
@MainActor
public final class ModulePreferences: ObservableObject {
public var repository: ModulePreferencesRepository?
public init() {
}
public func excludedEndpoints() -> ObservableList<ExtendedEndpoint> {
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)
}
}
}

View File

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

View File

@ -23,31 +23,122 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>. // along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
// //
import CommonUtils
import Foundation import Foundation
import PassepartoutKit import PassepartoutKit
public struct ProfileAttributes: Hashable, Codable { // WARNING: upcast to [String: AnyHashable] relies on CodableProfileCoder
public var fingerprint: UUID? // implementation returning JSONSerialization
public var lastUpdate: Date? extension ProfileType where UserInfoType == AnyHashable {
public var attributes: ProfileAttributes {
public var isAvailableForTV: Bool? ProfileAttributes(userInfo: userInfo as? [String: AnyHashable])
public init() {
}
public init(
fingerprint: UUID?,
lastUpdate: Date?,
isAvailableForTV: Bool?
) {
self.fingerprint = fingerprint
self.lastUpdate = lastUpdate
self.isAvailableForTV = isAvailableForTV
} }
} }
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<T>(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 { extension ProfileAttributes: CustomDebugStringConvertible {
public var debugDescription: String { public var debugDescription: String {
let descs = [ let descs = [
@ -59,50 +150,10 @@ extension ProfileAttributes: CustomDebugStringConvertible {
}, },
isAvailableForTV.map { isAvailableForTV.map {
"isAvailableForTV: \($0)" "isAvailableForTV: \($0)"
} },
"allPreferences: \(allPreferences)"
].compactMap { $0 } ].compactMap { $0 }
return "{\(descs.joined(separator: ", "))}" 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)
}
}
}

View File

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

View File

@ -37,9 +37,6 @@ public final class ProfileEditor: ObservableObject {
@Published @Published
public var isShared: Bool public var isShared: Bool
@Published
private var trackedPreferences: [UUID: ModulePreferencesRepository]
private(set) var removedModules: [UUID: any ModuleBuilder] private(set) var removedModules: [UUID: any ModuleBuilder]
public convenience init() { public convenience init() {
@ -50,7 +47,6 @@ public final class ProfileEditor: ObservableObject {
public init(profile: Profile) { public init(profile: Profile) {
editableProfile = profile.editable() editableProfile = profile.editable()
isShared = false isShared = false
trackedPreferences = [:]
removedModules = [:] removedModules = [:]
} }
@ -61,7 +57,6 @@ public final class ProfileEditor: ObservableObject {
activeModulesIds: Set(modules.map(\.id)) activeModulesIds: Set(modules.map(\.id))
) )
isShared = false isShared = false
trackedPreferences = [:]
removedModules = [:] removedModules = [:]
} }
} }
@ -208,35 +203,11 @@ extension ProfileEditor {
removedModules = [:] 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 @discardableResult
public func save(to profileManager: ProfileManager) async throws -> Profile { public func save(to profileManager: ProfileManager) 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)
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 return newProfile
} catch { } catch {
pp_log(.App.profiles, .fault, "Unable to save edited profile: \(error)") pp_log(.App.profiles, .fault, "Unable to save edited profile: \(error)")
@ -245,11 +216,6 @@ extension ProfileEditor {
} }
public func discard() { public func discard() {
trackedPreferences.forEach {
pp_log(.App.profiles, .debug, "Discard tracked preferences for module \($0.key)")
$0.value.discard()
}
trackedPreferences.removeAll()
} }
} }

View File

@ -24,6 +24,7 @@
// //
import CommonLibrary import CommonLibrary
import CommonUtils
import PassepartoutKit import PassepartoutKit
import SwiftUI import SwiftUI
@ -42,3 +43,23 @@ extension ProfileEditor {
} }
} }
} }
// MARK: - ModulePreferences
extension ProfileEditor {
public func excludedEndpoints(for moduleId: UUID) -> ObservableList<ExtendedEndpoint> {
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)
}
}
}
}

View File

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

View File

@ -41,15 +41,9 @@ extension Dependencies {
author: nil author: nil
) )
return PreferencesManager( return PreferencesManager(
modulesFactory: {
try AppData.cdModulePreferencesRepositoryV3(
context: preferencesStore.backgroundContext(),
moduleId: $0
)
},
providersFactory: { providersFactory: {
try AppData.cdProviderPreferencesRepositoryV3( try AppData.cdProviderPreferencesRepositoryV3(
context: preferencesStore.backgroundContext(), context: preferencesStore.context,
providerId: $0 providerId: $0
) )
} }

View File

@ -44,9 +44,9 @@ extension DefaultTunnelProcessor: PacketTunnelProcessor {
return return
} }
let modulesPreferences = try preferencesManager.preferencesRepository(forModuleWithId: moduleBuilder.id) let preferences = builder.attributes.preferences(inModule: moduleBuilder.id)
moduleBuilder.configurationBuilder?.remotes?.removeAll { moduleBuilder.configurationBuilder?.remotes?.removeAll {
modulesPreferences.isExcludedEndpoint($0) preferences.isExcludedEndpoint($0)
} }
if let providerId = moduleBuilder.providerId { if let providerId = moduleBuilder.providerId {