Track module preferences history in Core Data (#994)

Restore CDModulePreferencesV3 to track the history of module prefrences.

This way, excluded endpoints may be saved globally to Core Data as a
starting point. Then in Profile.userInfo we only save the relevant
exclusions for the current configuration.

The .excludedEndpoints relationship is therefore moved out of
CDProviderPreferencesV3.

Further refactoring:

- ModuleViewParameters now includes a ModulePreferences observable that
module views can observe
- Tunnel doesn't need access to PreferencesManager anymore (exclusions
are in Profile.userInfo)
This commit is contained in:
Davide 2024-12-10 14:13:10 +01:00 committed by GitHub
parent aeec943c58
commit 6f9c78b257
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 393 additions and 170 deletions

View File

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

View File

@ -0,0 +1,38 @@
//
// CDModulePreferencesV3.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/>.
//
import CoreData
import Foundation
@objc(CDModulePreferencesV3)
final class CDModulePreferencesV3: NSManagedObject {
@nonobjc static func fetchRequest() -> NSFetchRequest<CDModulePreferencesV3> {
NSFetchRequest<CDModulePreferencesV3>(entityName: "CDModulePreferencesV3")
}
@NSManaged var moduleId: UUID?
@NSManaged var lastUpdate: Date?
@NSManaged var excludedEndpoints: Set<CDExcludedEndpoint>?
}

View File

@ -35,5 +35,4 @@ final class CDProviderPreferencesV3: NSManagedObject {
@NSManaged var providerId: String? @NSManaged var providerId: String?
@NSManaged var lastUpdate: Date? @NSManaged var lastUpdate: Date?
@NSManaged var favoriteServerIds: Data? @NSManaged var favoriteServerIds: Data?
@NSManaged var excludedEndpoints: Set<CDExcludedEndpoint>?
} }

View File

@ -2,12 +2,16 @@
<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="providerPreferences" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CDProviderPreferencesV3" inverseName="excludedEndpoints" inverseEntity="CDProviderPreferencesV3"/> <relationship name="modulePreferences" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CDModulePreferencesV3" inverseName="excludedEndpoints" inverseEntity="CDModulePreferencesV3"/>
</entity>
<entity name="CDModulePreferencesV3" representedClassName="CDModulePreferencesV3" syncable="YES">
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="moduleId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<relationship name="excludedEndpoints" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="CDExcludedEndpoint" inverseName="modulePreferences" inverseEntity="CDExcludedEndpoint"/>
</entity> </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"/>
<attribute name="providerId" optional="YES" attributeType="String"/> <attribute name="providerId" optional="YES" attributeType="String"/>
<relationship name="excludedEndpoints" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="CDExcludedEndpoint" inverseName="providerPreferences" inverseEntity="CDExcludedEndpoint"/>
</entity> </entity>
</model> </model>

View File

@ -0,0 +1,120 @@
//
// 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: "moduleId == %@", 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.moduleId = 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 {
guard entity.excludedEndpoints?.contains(where: {
$0.endpoint == endpoint.rawValue
}) != true else {
return
}
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
}
}
}
}

View File

@ -55,7 +55,6 @@ private final class CDProviderPreferencesRepositoryV3: ProviderPreferencesReposi
guard $0.offset > 0 else { guard $0.offset > 0 else {
return return
} }
$0.element.excludedEndpoints?.forEach(context.delete(_:))
context.delete($0.element) context.delete($0.element)
} }
@ -95,35 +94,6 @@ private final class CDProviderPreferencesRepositoryV3: ProviderPreferencesReposi
} }
} }
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.providerPreferences = 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 { func save() throws {
try context.performAndWait { try context.performAndWait {
guard context.hasChanges else { guard context.hasChanges else {

View File

@ -34,9 +34,6 @@ 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

View File

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

View File

@ -30,9 +30,6 @@ import SwiftUI
struct OpenVPNView: View, ModuleDraftEditing { struct OpenVPNView: View, ModuleDraftEditing {
@EnvironmentObject
private var preferencesManager: PreferencesManager
@Environment(\.navigationPath) @Environment(\.navigationPath)
private var path private var path
@ -41,6 +38,9 @@ struct OpenVPNView: View, ModuleDraftEditing {
@ObservedObject @ObservedObject
var editor: ProfileEditor var editor: ProfileEditor
@ObservedObject
var modulePreferences: ModulePreferences
let impl: OpenVPNModule.Implementation? let impl: OpenVPNModule.Implementation?
private let isServerPushed: Bool private let isServerPushed: Bool
@ -51,15 +51,13 @@ struct OpenVPNView: View, ModuleDraftEditing {
@State @State
private var paywallReason: PaywallReason? private var paywallReason: PaywallReason?
@StateObject
private var providerPreferences = ProviderPreferences()
@StateObject @StateObject
private var errorHandler: ErrorHandler = .default() private var errorHandler: ErrorHandler = .default()
init(serverConfiguration: OpenVPN.Configuration) { init(serverConfiguration: OpenVPN.Configuration) {
module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder()) module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder())
editor = ProfileEditor(modules: [module]) editor = ProfileEditor(modules: [module])
modulePreferences = ModulePreferences()
assert(module.configurationBuilder != nil, "isServerPushed must imply module.configurationBuilder != nil") assert(module.configurationBuilder != nil, "isServerPushed must imply module.configurationBuilder != nil")
impl = nil impl = nil
isServerPushed = true isServerPushed = true
@ -68,6 +66,7 @@ struct OpenVPNView: View, ModuleDraftEditing {
init(module: OpenVPNModule.Builder, parameters: ModuleViewParameters) { init(module: OpenVPNModule.Builder, parameters: ModuleViewParameters) {
self.module = module self.module = module
editor = parameters.editor editor = parameters.editor
modulePreferences = parameters.preferences
impl = parameters.impl as? OpenVPNModule.Implementation impl = parameters.impl as? OpenVPNModule.Implementation
isServerPushed = false isServerPushed = false
} }
@ -129,7 +128,7 @@ private extension OpenVPNView {
var providerModifier: some ViewModifier { var providerModifier: some ViewModifier {
VPNProviderContentModifier( VPNProviderContentModifier(
providerId: providerId, providerId: providerId,
providerPreferences: providerPreferences, providerPreferences: nil,
selectedEntity: providerEntity, selectedEntity: providerEntity,
paywallReason: $paywallReason, paywallReason: $paywallReason,
entityDestination: Subroute.providerServer, entityDestination: Subroute.providerServer,
@ -200,17 +199,32 @@ private extension OpenVPNView {
private extension OpenVPNView { private extension OpenVPNView {
var excludedEndpoints: ObservableList<ExtendedEndpoint> { var excludedEndpoints: ObservableList<ExtendedEndpoint> {
if draft.wrappedValue.providerSelection != nil { editor.excludedEndpoints(for: module.id, preferences: modulePreferences)
return providerPreferences.excludedEndpoints()
} else {
return editor.excludedEndpoints(for: module.id)
}
} }
func onSelectServer(server: VPNServer, preset: VPNPreset<OpenVPN.Configuration>) { func onSelectServer(server: VPNServer, preset: VPNPreset<OpenVPN.Configuration>) {
draft.wrappedValue.providerEntity = VPNEntity(server: server, preset: preset) draft.wrappedValue.providerEntity = VPNEntity(server: server, preset: preset)
resetExcludedEndpointsWithCurrentProviderEntity()
path.wrappedValue.removeLast() path.wrappedValue.removeLast()
} }
// filter out exclusions unrelated to current server
func resetExcludedEndpointsWithCurrentProviderEntity() {
do {
let cfg = try draft.wrappedValue.providerSelection?.configuration()
editor.profile.attributes.editPreferences(inModule: module.id) {
if let cfg {
$0.excludedEndpoints = Set(cfg.remotes?.filter {
modulePreferences.isExcludedEndpoint($0)
} ?? [])
} else {
$0.excludedEndpoints = []
}
}
} catch {
pp_log(.app, .error, "Unable to build provider configuration for excluded endpoints: \(error)")
}
}
} }
// MARK: - Previews // MARK: - Previews

View File

@ -28,12 +28,19 @@ 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 preferences = ModulePreferences()
var body: some View { var body: some View {
debugChanges() debugChanges()
return Group { return Group {
@ -52,8 +59,24 @@ 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: preferences,
moduleId: moduleId moduleId: moduleId
)) ))
.onLoad {
do {
let repository = try preferencesManager.preferencesRepository(forModuleWithId: moduleId)
preferences.setRepository(repository)
} catch {
pp_log(.app, .error, "Unable to load preferences for module \(moduleId): \(error)")
}
}
.onDisappear {
do {
try preferences.save()
} catch {
pp_log(.app, .error, "Unable to save preferences for module \(moduleId): \(error)")
}
}
} }
var emptyView: some View { var emptyView: some View {

View File

@ -43,9 +43,6 @@ 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

View File

@ -188,13 +188,14 @@ private extension ProviderContentModifier {
if let providerId { if let providerId {
do { do {
pp_log(.app, .debug, "Load preferences for provider \(providerId)") pp_log(.app, .debug, "Load preferences for provider \(providerId)")
providerPreferences.repository = try preferencesManager.preferencesRepository(forProviderWithId: providerId) let repository = try preferencesManager.preferencesRepository(forProviderWithId: providerId)
providerPreferences.setRepository(repository)
} 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)")
providerPreferences.repository = nil providerPreferences.setRepository(nil)
} }
} else { } else {
providerPreferences.repository = nil providerPreferences.setRepository(nil)
} }
} }

View File

@ -164,7 +164,8 @@ private extension VPNProviderServerView {
private extension VPNProviderServerView { private extension VPNProviderServerView {
func loadInitialServers() async { func loadInitialServers() async {
do { do {
providerPreferences.repository = try preferencesManager.preferencesRepository(forProviderWithId: providerId) let repository = try preferencesManager.preferencesRepository(forProviderWithId: providerId)
providerPreferences.setRepository(repository)
} 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

@ -0,0 +1,56 @@
//
// ModulePreferences.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/>.
//
import CommonUtils
import Foundation
import PassepartoutKit
@MainActor
public final class ModulePreferences: ObservableObject {
private var repository: ModulePreferencesRepository?
public init() {
}
public func setRepository(_ repository: ModulePreferencesRepository?) {
self.repository = repository
}
public func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool {
repository?.isExcludedEndpoint(endpoint) ?? false
}
public func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
repository?.addExcludedEndpoint(endpoint)
}
public func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
repository?.removeExcludedEndpoint(endpoint)
}
public func save() throws {
try repository?.save()
}
}

View File

@ -28,11 +28,17 @@ 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()
} }
@ -40,25 +46,18 @@ 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)
} }
} }
@MainActor
extension PreferencesManager {
public func preferences(forProviderWithId providerId: ProviderID) throws -> ProviderPreferences {
let object = ProviderPreferences()
object.repository = try providersFactory(providerId)
return object
}
}
// MARK: - Dummy // MARK: - Dummy
private final class DummyProviderPreferencesRepository: ProviderPreferencesRepository { private final class DummyModulePreferencesRepository: ModulePreferencesRepository {
var favoriteServers: Set<String> = []
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool { func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool {
false false
} }
@ -72,3 +71,10 @@ private final class DummyProviderPreferencesRepository: ProviderPreferencesRepos
func save() throws { func save() throws {
} }
} }
private final class DummyProviderPreferencesRepository: ProviderPreferencesRepository {
var favoriteServers: Set<String> = []
func save() throws {
}
}

View File

@ -29,11 +29,15 @@ import PassepartoutKit
@MainActor @MainActor
public final class ProviderPreferences: ObservableObject { public final class ProviderPreferences: ObservableObject {
public var repository: ProviderPreferencesRepository? private var repository: ProviderPreferencesRepository?
public init() { public init() {
} }
public func setRepository(_ repository: ProviderPreferencesRepository?) {
self.repository = repository
}
public var favoriteServers: Set<String> { public var favoriteServers: Set<String> {
get { get {
repository?.favoriteServers ?? [] repository?.favoriteServers ?? []
@ -44,16 +48,6 @@ public final class ProviderPreferences: ObservableObject {
} }
} }
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)
}
}
public func save() throws { public func save() throws {
try repository?.save() try repository?.save()
} }

View File

@ -40,17 +40,26 @@ extension ProfileAttributes {
self.userInfo = userInfo ?? [:] self.userInfo = userInfo ?? [:]
} }
public var excludedEndpoints: Set<ExtendedEndpoint> {
get {
Set(rawExcludedEndpoints.compactMap(ExtendedEndpoint.init(rawValue:)))
}
set {
rawExcludedEndpoints = newValue.map(\.rawValue)
}
}
public func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool { public func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool {
excludedEndpoints.contains(endpoint.rawValue) rawExcludedEndpoints.contains(endpoint.rawValue)
} }
public mutating func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) { public mutating func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
excludedEndpoints.append(endpoint.rawValue) rawExcludedEndpoints.append(endpoint.rawValue)
} }
public mutating func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) { public mutating func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
let rawValue = endpoint.rawValue let rawValue = endpoint.rawValue
excludedEndpoints.removeAll { rawExcludedEndpoints.removeAll {
$0 == rawValue $0 == rawValue
} }
} }
@ -58,7 +67,7 @@ extension ProfileAttributes {
} }
extension ProfileAttributes.ModulePreferences { extension ProfileAttributes.ModulePreferences {
var excludedEndpoints: [String] { var rawExcludedEndpoints: [String] {
get { get {
userInfo[Key.excludedEndpoints.rawValue] as? [String] ?? [] userInfo[Key.excludedEndpoints.rawValue] as? [String] ?? []
} }

View File

@ -0,0 +1,37 @@
//
// ModulePreferencesRepository.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/>.
//
import Foundation
import PassepartoutKit
public protocol ModulePreferencesRepository {
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool
func addExcludedEndpoint(_ endpoint: ExtendedEndpoint)
func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint)
func save() throws
}

View File

@ -29,11 +29,5 @@ import PassepartoutKit
public protocol ProviderPreferencesRepository { public protocol ProviderPreferencesRepository {
var favoriteServers: Set<String> { get set } var favoriteServers: Set<String> { get set }
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool
func addExcludedEndpoint(_ endpoint: ExtendedEndpoint)
func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint)
func save() throws func save() throws
} }

View File

@ -23,6 +23,7 @@
// 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
@ -33,6 +34,7 @@ extension ModuleBuilder where Self: ModuleViewProviding {
NavigationStack { NavigationStack {
moduleView(with: .init( moduleView(with: .init(
editor: ProfileEditor(modules: [self]), editor: ProfileEditor(modules: [self]),
preferences: ModulePreferences(),
impl: nil impl: nil
)) ))
.navigationTitle(title) .navigationTitle(title)

View File

@ -47,7 +47,7 @@ extension ProfileEditor {
// MARK: - ModulePreferences // MARK: - ModulePreferences
extension ProfileEditor { extension ProfileEditor {
public func excludedEndpoints(for moduleId: UUID) -> ObservableList<ExtendedEndpoint> { public func excludedEndpoints(for moduleId: UUID, preferences: ModulePreferences) -> ObservableList<ExtendedEndpoint> {
ObservableList { [weak self] endpoint in ObservableList { [weak self] endpoint in
self?.profile.attributes.preference(inModule: moduleId) { self?.profile.attributes.preference(inModule: moduleId) {
$0.isExcludedEndpoint(endpoint) $0.isExcludedEndpoint(endpoint)
@ -56,10 +56,12 @@ extension ProfileEditor {
self?.profile.attributes.editPreferences(inModule: moduleId) { self?.profile.attributes.editPreferences(inModule: moduleId) {
$0.addExcludedEndpoint(endpoint) $0.addExcludedEndpoint(endpoint)
} }
preferences.addExcludedEndpoint(endpoint)
} remove: { [weak self] endpoint in } remove: { [weak self] endpoint in
self?.profile.attributes.editPreferences(inModule: moduleId) { self?.profile.attributes.editPreferences(inModule: moduleId) {
$0.removeExcludedEndpoint(endpoint) $0.removeExcludedEndpoint(endpoint)
} }
preferences.removeExcludedEndpoint(endpoint)
} }
} }
} }

View File

@ -36,11 +36,12 @@ public final class DefaultModuleViewFactory: ModuleViewFactory {
} }
@ViewBuilder @ViewBuilder
public func view(with editor: ProfileEditor, moduleId: UUID) -> some View { public func view(with editor: ProfileEditor, preferences: ModulePreferences, 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, moduleId: UUID) -> Content func view(with editor: ProfileEditor, preferences: ModulePreferences, moduleId: UUID) -> Content
} }

View File

@ -37,14 +37,18 @@ 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
self.impl = impl self.impl = impl
} }
} }

View File

@ -72,7 +72,7 @@ final class ProfileAttributesTests: XCTestCase {
let excludedEndpoints: [String] = [ let excludedEndpoints: [String] = [
"1.1.1.1:UDP6:1000", "1.1.1.1:UDP6:1000",
"2.2.2.2:TCP4:2000", "2.2.2.2:TCP4:2000",
"3.3.3.3:TCP:3000", "3.3.3.3:TCP:3000"
] ]
let moduleUserInfo: [String: AnyHashable] = [ let moduleUserInfo: [String: AnyHashable] = [
"excludedEndpoints": excludedEndpoints "excludedEndpoints": excludedEndpoints
@ -89,7 +89,7 @@ final class ProfileAttributesTests: XCTestCase {
for moduleId in [moduleId1, moduleId2] { for moduleId in [moduleId1, moduleId2] {
let module = sut.preferences(inModule: moduleId) let module = sut.preferences(inModule: moduleId)
XCTAssertEqual(module.userInfo, moduleUserInfo) XCTAssertEqual(module.userInfo, moduleUserInfo)
XCTAssertEqual(module.excludedEndpoints, excludedEndpoints) XCTAssertEqual(module.rawExcludedEndpoints, excludedEndpoints)
} }
} }
@ -99,7 +99,7 @@ final class ProfileAttributesTests: XCTestCase {
let excludedEndpoints: [String] = [ let excludedEndpoints: [String] = [
"1.1.1.1:UDP6:1000", "1.1.1.1:UDP6:1000",
"2.2.2.2:TCP4:2000", "2.2.2.2:TCP4:2000",
"3.3.3.3:TCP:3000", "3.3.3.3:TCP:3000"
] ]
let moduleUserInfo: [String: AnyHashable] = [ let moduleUserInfo: [String: AnyHashable] = [
"excludedEndpoints": excludedEndpoints "excludedEndpoints": excludedEndpoints
@ -114,7 +114,7 @@ final class ProfileAttributesTests: XCTestCase {
var sut = ProfileAttributes(userInfo: nil) var sut = ProfileAttributes(userInfo: nil)
for moduleId in [moduleId1, moduleId2] { for moduleId in [moduleId1, moduleId2] {
var module = sut.preferences(inModule: moduleId1) var module = sut.preferences(inModule: moduleId1)
module.excludedEndpoints = excludedEndpoints module.rawExcludedEndpoints = excludedEndpoints
XCTAssertEqual(module.userInfo, moduleUserInfo) XCTAssertEqual(module.userInfo, moduleUserInfo)
sut.setPreferences(module, inModule: moduleId) sut.setPreferences(module, inModule: moduleId)
} }

View File

@ -36,8 +36,6 @@
0E8DFD532D05FE5A00531CDE /* Dependencies+PassepartoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8DFD4C2D05FE5A00531CDE /* Dependencies+PassepartoutKit.swift */; }; 0E8DFD532D05FE5A00531CDE /* Dependencies+PassepartoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8DFD4C2D05FE5A00531CDE /* Dependencies+PassepartoutKit.swift */; };
0E8DFD542D05FE5A00531CDE /* Dependencies+IAPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8DFD4B2D05FE5A00531CDE /* Dependencies+IAPManager.swift */; }; 0E8DFD542D05FE5A00531CDE /* Dependencies+IAPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8DFD4B2D05FE5A00531CDE /* Dependencies+IAPManager.swift */; };
0E8DFD562D05FE5A00531CDE /* Dependencies+CoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8DFD492D05FE5A00531CDE /* Dependencies+CoreData.swift */; }; 0E8DFD562D05FE5A00531CDE /* Dependencies+CoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8DFD492D05FE5A00531CDE /* Dependencies+CoreData.swift */; };
0E8DFD592D05FF0400531CDE /* Dependencies+PreferencesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8DFD582D05FF0400531CDE /* Dependencies+PreferencesManager.swift */; };
0E8DFD5A2D05FF0400531CDE /* Dependencies+PreferencesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8DFD582D05FF0400531CDE /* Dependencies+PreferencesManager.swift */; };
0E916B782CF80FD60072921A /* ProfileEditorScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E916B772CF80FD60072921A /* ProfileEditorScreen.swift */; }; 0E916B782CF80FD60072921A /* ProfileEditorScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E916B772CF80FD60072921A /* ProfileEditorScreen.swift */; };
0E916B7C2CF811EB0072921A /* XCUIElement+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E916B7B2CF811EB0072921A /* XCUIElement+Extensions.swift */; }; 0E916B7C2CF811EB0072921A /* XCUIElement+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E916B7B2CF811EB0072921A /* XCUIElement+Extensions.swift */; };
0E94EE582B93554B00588243 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7E3D672B9345FD002BBDB4 /* PacketTunnelProvider.swift */; }; 0E94EE582B93554B00588243 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7E3D672B9345FD002BBDB4 /* PacketTunnelProvider.swift */; };
@ -170,7 +168,6 @@
0E8DFD4B2D05FE5A00531CDE /* Dependencies+IAPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dependencies+IAPManager.swift"; sourceTree = "<group>"; }; 0E8DFD4B2D05FE5A00531CDE /* Dependencies+IAPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dependencies+IAPManager.swift"; sourceTree = "<group>"; };
0E8DFD4C2D05FE5A00531CDE /* Dependencies+PassepartoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dependencies+PassepartoutKit.swift"; sourceTree = "<group>"; }; 0E8DFD4C2D05FE5A00531CDE /* Dependencies+PassepartoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dependencies+PassepartoutKit.swift"; sourceTree = "<group>"; };
0E8DFD4D2D05FE5A00531CDE /* Dependencies+Processors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dependencies+Processors.swift"; sourceTree = "<group>"; }; 0E8DFD4D2D05FE5A00531CDE /* Dependencies+Processors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dependencies+Processors.swift"; sourceTree = "<group>"; };
0E8DFD582D05FF0400531CDE /* Dependencies+PreferencesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dependencies+PreferencesManager.swift"; sourceTree = "<group>"; };
0E916B772CF80FD60072921A /* ProfileEditorScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEditorScreen.swift; sourceTree = "<group>"; }; 0E916B772CF80FD60072921A /* ProfileEditorScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEditorScreen.swift; sourceTree = "<group>"; };
0E916B7B2CF811EB0072921A /* XCUIElement+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCUIElement+Extensions.swift"; sourceTree = "<group>"; }; 0E916B7B2CF811EB0072921A /* XCUIElement+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCUIElement+Extensions.swift"; sourceTree = "<group>"; };
0E94EE5C2B93570600588243 /* Tunnel.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Tunnel.plist; sourceTree = "<group>"; }; 0E94EE5C2B93570600588243 /* Tunnel.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Tunnel.plist; sourceTree = "<group>"; };
@ -350,7 +347,6 @@
0E8DFD492D05FE5A00531CDE /* Dependencies+CoreData.swift */, 0E8DFD492D05FE5A00531CDE /* Dependencies+CoreData.swift */,
0E8DFD4B2D05FE5A00531CDE /* Dependencies+IAPManager.swift */, 0E8DFD4B2D05FE5A00531CDE /* Dependencies+IAPManager.swift */,
0E8DFD4C2D05FE5A00531CDE /* Dependencies+PassepartoutKit.swift */, 0E8DFD4C2D05FE5A00531CDE /* Dependencies+PassepartoutKit.swift */,
0E8DFD582D05FF0400531CDE /* Dependencies+PreferencesManager.swift */,
); );
path = Shared; path = Shared;
sourceTree = "<group>"; sourceTree = "<group>";
@ -720,7 +716,6 @@
0E81955A2CFDA75200CC8FFD /* Dependencies.swift in Sources */, 0E81955A2CFDA75200CC8FFD /* Dependencies.swift in Sources */,
0E6EEEE32CF8CABA0076E2B0 /* AppContext+Testing.swift in Sources */, 0E6EEEE32CF8CABA0076E2B0 /* AppContext+Testing.swift in Sources */,
0E6EEEE42CF8CABA0076E2B0 /* ProfileManager+Testing.swift in Sources */, 0E6EEEE42CF8CABA0076E2B0 /* ProfileManager+Testing.swift in Sources */,
0E8DFD592D05FF0400531CDE /* Dependencies+PreferencesManager.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -769,7 +764,6 @@
0E8DFD532D05FE5A00531CDE /* Dependencies+PassepartoutKit.swift in Sources */, 0E8DFD532D05FE5A00531CDE /* Dependencies+PassepartoutKit.swift in Sources */,
0E8DFD542D05FE5A00531CDE /* Dependencies+IAPManager.swift in Sources */, 0E8DFD542D05FE5A00531CDE /* Dependencies+IAPManager.swift in Sources */,
0E8DFD562D05FE5A00531CDE /* Dependencies+CoreData.swift in Sources */, 0E8DFD562D05FE5A00531CDE /* Dependencies+CoreData.swift in Sources */,
0E8DFD5A2D05FF0400531CDE /* Dependencies+PreferencesManager.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@ -24,6 +24,7 @@
// //
import AppData import AppData
import AppDataPreferences
import AppDataProfiles import AppDataProfiles
import AppDataProviders import AppDataProviders
import CommonLibrary import CommonLibrary
@ -122,7 +123,30 @@ extension AppContext {
return MigrationManager(profileStrategy: profileStrategy, simulation: migrationSimulation) return MigrationManager(profileStrategy: profileStrategy, simulation: migrationSimulation)
}() }()
let preferencesManager = dependencies.preferencesManager(withCloudKit: true) let preferencesManager: PreferencesManager = {
let preferencesStore = CoreDataPersistentStore(
logger: dependencies.coreDataLogger(),
containerName: Constants.shared.containers.preferences,
baseURL: BundleConfiguration.urlForGroupDocuments,
model: AppData.cdPreferencesModel,
cloudKitIdentifier: BundleConfiguration.mainString(for: .cloudKitPreferencesId),
author: nil
)
return PreferencesManager(
modulesFactory: {
try AppData.cdModulePreferencesRepositoryV3(
context: preferencesStore.context,
moduleId: $0
)
},
providersFactory: {
try AppData.cdProviderPreferencesRepositoryV3(
context: preferencesStore.context,
providerId: $0
)
}
)
}()
return AppContext( return AppContext(
iapManager: iapManager, iapManager: iapManager,

View File

@ -1,52 +0,0 @@
//
// Dependencies+PreferencesManager.swift
// Passepartout
//
// Created by Davide De Rosa on 12/2/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 AppDataPreferences
import CommonLibrary
import CommonUtils
import Foundation
import PassepartoutKit
extension Dependencies {
func preferencesManager(withCloudKit: Bool) -> PreferencesManager {
let preferencesStore = CoreDataPersistentStore(
logger: coreDataLogger(),
containerName: Constants.shared.containers.preferences,
baseURL: BundleConfiguration.urlForGroupDocuments,
model: AppData.cdPreferencesModel,
cloudKitIdentifier: withCloudKit ? BundleConfiguration.mainString(for: .cloudKitPreferencesId) : nil,
author: nil
)
return PreferencesManager(
providersFactory: {
try AppData.cdProviderPreferencesRepositoryV3(
context: preferencesStore.context,
providerId: $0
)
}
)
}
}

View File

@ -28,10 +28,7 @@ import Foundation
import PassepartoutKit import PassepartoutKit
final class DefaultTunnelProcessor: Sendable { final class DefaultTunnelProcessor: Sendable {
private let preferencesManager: PreferencesManager init() {
init(preferencesManager: PreferencesManager) {
self.preferencesManager = preferencesManager
} }
} }
@ -49,13 +46,6 @@ extension DefaultTunnelProcessor: PacketTunnelProcessor {
preferences.isExcludedEndpoint($0) preferences.isExcludedEndpoint($0)
} }
if let providerId = moduleBuilder.providerId {
let providerPreferences = try preferencesManager.preferencesRepository(forProviderWithId: providerId)
moduleBuilder.configurationBuilder?.remotes?.removeAll {
providerPreferences.isExcludedEndpoint($0)
}
}
let module = try moduleBuilder.tryBuild() let module = try moduleBuilder.tryBuild()
builder.saveModule(module) builder.saveModule(module)
} }

View File

@ -37,10 +37,7 @@ extension TunnelContext {
betaChecker: dependencies.betaChecker(), betaChecker: dependencies.betaChecker(),
productsAtBuild: dependencies.productsAtBuild() productsAtBuild: dependencies.productsAtBuild()
) )
let processor: PacketTunnelProcessor = { let processor = DefaultTunnelProcessor()
let preferencesManager = dependencies.preferencesManager(withCloudKit: false)
return DefaultTunnelProcessor(preferencesManager: preferencesManager)
}()
return TunnelContext( return TunnelContext(
iapManager: iapManager, iapManager: iapManager,
processor: processor processor: processor