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 modulePreferences: CDModulePreferencesV3?
@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="">
<entity name="CDExcludedEndpoint" representedClassName="CDExcludedEndpoint" syncable="YES">
<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"/>
</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">
<attribute name="favoriteServerIds" optional="YES" attributeType="Binary"/>
<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
private var paywallReason: PaywallReason?
@StateObject
private var preferences = ModulePreferences()
@StateObject
private var providerPreferences = ProviderPreferences()
@ -88,13 +85,6 @@ struct OpenVPNView: View, ModuleDraftEditing {
.navigationDestination(for: Subroute.self, destination: destination)
.themeAnimation(on: draft.wrappedValue.providerId, category: .modules)
.withErrorHandler(errorHandler)
.onLoad {
editor.loadPreferences(
preferences,
from: preferencesManager,
forModuleWithId: module.id
)
}
}
}
@ -213,7 +203,7 @@ private extension OpenVPNView {
if draft.wrappedValue.providerSelection != nil {
return providerPreferences.excludedEndpoints()
} else {
return preferences.excludedEndpoints()
return editor.excludedEndpoints(for: module.id)
}
}

View File

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

View File

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

View File

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

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/>.
//
import CommonUtils
import Foundation
import PassepartoutKit
public struct ProfileAttributes: Hashable, Codable {
public var fingerprint: UUID?
// WARNING: upcast to [String: AnyHashable] relies on CodableProfileCoder
// implementation returning JSONSerialization
public var lastUpdate: Date?
public var isAvailableForTV: Bool?
public init() {
}
public init(
fingerprint: UUID?,
lastUpdate: Date?,
isAvailableForTV: Bool?
) {
self.fingerprint = fingerprint
self.lastUpdate = lastUpdate
self.isAvailableForTV = isAvailableForTV
extension ProfileType where UserInfoType == AnyHashable {
public var attributes: ProfileAttributes {
ProfileAttributes(userInfo: userInfo as? [String: AnyHashable])
}
}
extension MutableProfileType where UserInfoType == AnyHashable {
public var attributes: ProfileAttributes {
get {
ProfileAttributes(userInfo: userInfo as? [String: AnyHashable])
}
set {
userInfo = newValue.userInfo
}
}
}
// MARK: - ProfileAttributes
public struct ProfileAttributes {
fileprivate enum Key: String {
case fingerprint
case lastUpdate
case isAvailableForTV
case preferences
}
private(set) var userInfo: [String: AnyHashable]
init(userInfo: [String: AnyHashable]?) {
self.userInfo = userInfo ?? [:]
}
}
// MARK: Basic
extension ProfileAttributes {
public var fingerprint: UUID? {
get {
guard let string = userInfo[Key.fingerprint.rawValue] as? String else {
return nil
}
return UUID(uuidString: string)
}
set {
userInfo[Key.fingerprint.rawValue] = newValue?.uuidString
}
}
public var lastUpdate: Date? {
get {
guard let interval = userInfo[Key.lastUpdate.rawValue] as? TimeInterval else {
return nil
}
return Date(timeIntervalSinceReferenceDate: interval)
}
set {
userInfo[Key.lastUpdate.rawValue] = newValue?.timeIntervalSinceReferenceDate
}
}
public var isAvailableForTV: Bool? {
get {
userInfo[Key.isAvailableForTV.rawValue] as? Bool
}
set {
userInfo[Key.isAvailableForTV.rawValue] = newValue
}
}
}
// MARK: Preferences
extension ProfileAttributes {
public func preferences(inModule moduleId: UUID) -> ModulePreferences {
ModulePreferences(userInfo: allPreferences[moduleId.uuidString] as? [String: AnyHashable])
}
public mutating func setPreferences(_ module: ModulePreferences, inModule moduleId: UUID) {
allPreferences[moduleId.uuidString] = module.userInfo
}
public func preference<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 {
public var debugDescription: String {
let descs = [
@ -59,50 +150,10 @@ extension ProfileAttributes: CustomDebugStringConvertible {
},
isAvailableForTV.map {
"isAvailableForTV: \($0)"
}
},
"allPreferences: \(allPreferences)"
].compactMap { $0 }
return "{\(descs.joined(separator: ", "))}"
}
}
// MARK: - UserInfoCodable
extension ProfileAttributes: UserInfoCodable {
public init?(userInfo: AnyHashable?) {
do {
let data = try JSONSerialization.data(withJSONObject: userInfo ?? [:])
self = try JSONDecoder().decode(ProfileAttributes.self, from: data)
} catch {
pp_log(.App.profiles, .error, "Unable to decode ProfileAttributes from dictionary: \(error)")
return nil
}
}
public var userInfo: AnyHashable? {
do {
let data = try JSONEncoder().encode(self)
return try JSONSerialization.jsonObject(with: data) as? AnyHashable
} catch {
pp_log(.App.profiles, .error, "Unable to encode ProfileAttributes to dictionary: \(error)")
return nil
}
}
}
extension Profile {
public var attributes: ProfileAttributes {
userInfo() ?? ProfileAttributes()
}
}
extension Profile.Builder {
public var attributes: ProfileAttributes {
get {
userInfo() ?? ProfileAttributes()
}
set {
setUserInfo(newValue)
}
}
}

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
public var isShared: Bool
@Published
private var trackedPreferences: [UUID: ModulePreferencesRepository]
private(set) var removedModules: [UUID: any ModuleBuilder]
public convenience init() {
@ -50,7 +47,6 @@ public final class ProfileEditor: ObservableObject {
public init(profile: Profile) {
editableProfile = profile.editable()
isShared = false
trackedPreferences = [:]
removedModules = [:]
}
@ -61,7 +57,6 @@ public final class ProfileEditor: ObservableObject {
activeModulesIds: Set(modules.map(\.id))
)
isShared = false
trackedPreferences = [:]
removedModules = [:]
}
}
@ -208,35 +203,11 @@ extension ProfileEditor {
removedModules = [:]
}
public func loadPreferences(
_ preferences: ModulePreferences,
from manager: PreferencesManager,
forModuleWithId moduleId: UUID
) {
do {
pp_log(.App.profiles, .debug, "Track preferences for module \(moduleId)")
let repository = try trackedPreferences[moduleId] ?? manager.preferencesRepository(forModuleWithId: moduleId)
preferences.repository = repository
trackedPreferences[moduleId] = repository // @Published
} catch {
pp_log(.App.profiles, .error, "Unable to track preferences for module \(moduleId): \(error)")
}
}
@discardableResult
public func save(to profileManager: ProfileManager) async throws -> Profile {
do {
let newProfile = try build()
try await profileManager.save(newProfile, isLocal: true, remotelyShared: isShared)
trackedPreferences.forEach {
do {
pp_log(.App.profiles, .debug, "Save tracked preferences for module \($0.key)")
try $0.value.save()
} catch {
pp_log(.App.profiles, .error, "Unable to save preferences for profile \(profile.id): \(error)")
}
}
trackedPreferences.removeAll()
return newProfile
} catch {
pp_log(.App.profiles, .fault, "Unable to save edited profile: \(error)")
@ -245,11 +216,6 @@ extension ProfileEditor {
}
public func discard() {
trackedPreferences.forEach {
pp_log(.App.profiles, .debug, "Discard tracked preferences for module \($0.key)")
$0.value.discard()
}
trackedPreferences.removeAll()
}
}

View File

@ -24,6 +24,7 @@
//
import CommonLibrary
import CommonUtils
import PassepartoutKit
import SwiftUI
@ -42,3 +43,23 @@ extension ProfileEditor {
}
}
}
// MARK: - ModulePreferences
extension ProfileEditor {
public func excludedEndpoints(for moduleId: UUID) -> ObservableList<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
)
return PreferencesManager(
modulesFactory: {
try AppData.cdModulePreferencesRepositoryV3(
context: preferencesStore.backgroundContext(),
moduleId: $0
)
},
providersFactory: {
try AppData.cdProviderPreferencesRepositoryV3(
context: preferencesStore.backgroundContext(),
context: preferencesStore.context,
providerId: $0
)
}

View File

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