Store complex preferences to Core Data (#981)

Replace favorites entities with a PreferencesManager, that returns
observables for:

- Module preferences (by module UUID)
- Provider preferences (by ProviderID)

Automate preferences availability in:

- Module views (empty for now)
- VPN server view (favorites)

Synchronize preferences by making this a CloudKit container. Preferences
are also available in the Tunnel by storing the container in the App
Group.
This commit is contained in:
Davide 2024-12-06 11:24:51 +01:00 committed by GitHub
parent f8655b09af
commit dfae6afcb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 832 additions and 185 deletions

View File

@ -15,11 +15,21 @@ let package = Package(
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "AppUIMain",
targets: ["AppUIMainWrapper"]
targets: [
"AppDataPreferences",
"AppDataProfiles",
"AppDataProviders",
"AppUIMainWrapper"
]
),
.library(
name: "AppUITV",
targets: ["AppUITVWrapper"]
targets: [
"AppDataPreferences",
"AppDataProfiles",
"AppDataProviders",
"AppUITVWrapper"
]
),
.library(
name: "CommonIAP",
@ -39,7 +49,10 @@ let package = Package(
),
.library(
name: "TunnelLibrary",
targets: ["CommonLibrary"]
targets: [
"AppDataPreferences",
"CommonLibrary"
]
),
.library(
name: "UILibrary",
@ -69,6 +82,16 @@ let package = Package(
name: "AppData",
dependencies: []
),
.target(
name: "AppDataPreferences",
dependencies: [
"AppData",
"CommonLibrary"
],
resources: [
.process("Preferences.xcdatamodeld")
]
),
.target(
name: "AppDataProfiles",
dependencies: [
@ -166,8 +189,6 @@ let package = Package(
.target(
name: "UILibrary",
dependencies: [
"AppDataProfiles",
"AppDataProviders",
"CommonAPI",
"CommonLibrary",
"UITesting"

View File

@ -0,0 +1,39 @@
//
// AppData+Preferences.swift
// Passepartout
//
// Created by Davide De Rosa on 12/6/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 CoreData
import Foundation
extension AppData {
@MainActor
public static let cdPreferencesModel: NSManagedObjectModel = {
guard let model: NSManagedObjectModel = .mergedModel(from: [.module]) else {
fatalError("Unable to build Core Data model (Preferences v3)")
}
return model
}()
}

View File

@ -0,0 +1,36 @@
//
// 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?
}

View File

@ -0,0 +1,37 @@
//
// CDProviderPreferencesV3.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(CDProviderPreferencesV3)
final class CDProviderPreferencesV3: NSManagedObject {
@nonobjc static func fetchRequest() -> NSFetchRequest<CDProviderPreferencesV3> {
NSFetchRequest<CDProviderPreferencesV3>(entityName: "CDProviderPreferencesV3")
}
@NSManaged var providerId: String?
@NSManaged var favoriteServerIds: Data?
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23507" systemVersion="23H222" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
<entity name="CDModulePreferencesV3" representedClassName="CDModulePreferencesV3" syncable="YES">
<attribute name="uuid" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
</entity>
<entity name="CDProviderPreferencesV3" representedClassName="CDProviderPreferencesV3" syncable="YES">
<attribute name="favoriteServerIds" optional="YES" attributeType="Binary"/>
<attribute name="providerId" optional="YES" attributeType="String"/>
</entity>
</model>

View File

@ -0,0 +1,89 @@
//
// 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 {
@MainActor
public static func cdModulePreferencesRepositoryV3(context: NSManagedObjectContext) -> ModulePreferencesRepository {
CDModulePreferencesRepositoryV3(context: context)
}
}
// MARK: - Repository
private final class CDModulePreferencesRepositoryV3: ModulePreferencesRepository {
private nonisolated let context: NSManagedObjectContext
init(context: NSManagedObjectContext) {
self.context = context
}
func modulePreferencesProxy(in moduleId: UUID) throws -> ModulePreferencesProxy {
let entity = try context.performAndWait {
let request = CDModulePreferencesV3.fetchRequest()
request.predicate = NSPredicate(format: "uuid == %@", moduleId.uuidString)
do {
let entity = try request.execute().first ?? CDModulePreferencesV3(context: context)
entity.uuid = moduleId
return entity
} catch {
pp_log(.app, .error, "Unable to load preferences for module \(moduleId): \(error)")
throw error
}
}
return CDModulePreferencesProxy(context: context, entity: entity)
}
}
// MARK: - Preference
private final class CDModulePreferencesProxy: ModulePreferencesProxy {
private let context: NSManagedObjectContext
private let entity: CDModulePreferencesV3
init(context: NSManagedObjectContext, entity: CDModulePreferencesV3) {
self.context = context
self.entity = entity
}
func save() throws {
guard context.hasChanges else {
return
}
do {
try context.save()
} catch {
context.rollback()
throw error
}
}
}

View File

@ -0,0 +1,114 @@
//
// CDProviderPreferencesRepositoryV3.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 {
@MainActor
public static func cdProviderPreferencesRepositoryV3(context: NSManagedObjectContext) -> ProviderPreferencesRepository {
CDProviderPreferencesRepositoryV3(context: context)
}
}
// MARK: - Repository
private final class CDProviderPreferencesRepositoryV3: ProviderPreferencesRepository {
private nonisolated let context: NSManagedObjectContext
init(context: NSManagedObjectContext) {
self.context = context
}
func providerPreferencesProxy(in providerId: ProviderID) throws -> ProviderPreferencesProxy {
let entity = try context.performAndWait {
let request = CDProviderPreferencesV3.fetchRequest()
request.predicate = NSPredicate(format: "providerId == %@", providerId.rawValue)
do {
let entity = try request.execute().first ?? CDProviderPreferencesV3(context: context)
entity.providerId = providerId.rawValue
return entity
} catch {
pp_log(.app, .error, "Unable to load preferences for provider \(providerId): \(error)")
throw error
}
}
return CDProviderPreferencesProxy(context: context, entity: entity)
}
}
// MARK: - Preference
private final class CDProviderPreferencesProxy: ProviderPreferencesProxy {
private let context: NSManagedObjectContext
private let entity: CDProviderPreferencesV3
init(context: NSManagedObjectContext, entity: CDProviderPreferencesV3) {
self.context = context
self.entity = entity
}
var favoriteServers: Set<String> {
get {
do {
return try context.performAndWait {
guard let data = entity.favoriteServerIds else {
return []
}
return try JSONDecoder().decode(Set<String>.self, from: data)
}
} catch {
pp_log(.app, .error, "Unable to get favoriteServers: \(error)")
return []
}
}
set {
do {
try context.performAndWait {
entity.favoriteServerIds = try JSONEncoder().encode(newValue)
}
} catch {
pp_log(.app, .error, "Unable to set favoriteServers: \(error)")
}
}
}
func save() throws {
guard context.hasChanges else {
return
}
do {
try context.save()
} catch {
context.rollback()
throw error
}
}
}

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23H124" 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="CDProfileV3" representedClassName="CDProfileV3" elementID="CDProfile" versionHashModifier="1" syncable="YES">
<attribute name="encoded" optional="YES" attributeType="String" allowsCloudEncryption="YES"/>
<attribute name="fingerprint" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>

View File

@ -36,7 +36,7 @@ extension AppData {
}
}
actor CDProviderRepositoryV3: NSObject, ProviderRepository {
private actor CDProviderRepositoryV3: NSObject, ProviderRepository {
private nonisolated let context: NSManagedObjectContext
private nonisolated let providersSubject: CurrentValueSubject<[Provider], Never>

View File

@ -33,10 +33,15 @@ struct DNSView: View, ModuleDraftEditing {
@EnvironmentObject
private var theme: Theme
let module: DNSModule.Builder
@ObservedObject
var editor: ProfileEditor
let module: DNSModule.Builder
init(module: DNSModule.Builder, parameters: ModuleViewParameters) {
self.module = module
editor = parameters.editor
}
var body: some View {
debugChanges()

View File

@ -23,12 +23,13 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import PassepartoutKit
import SwiftUI
import UILibrary
extension DNSModule.Builder: ModuleViewProviding {
public func moduleView(with editor: ProfileEditor, impl: ModuleImplementation?) -> some View {
DNSView(editor: editor, module: self)
public func moduleView(with parameters: ModuleViewParameters) -> some View {
DNSView(module: self, parameters: parameters)
}
}

View File

@ -23,12 +23,13 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import PassepartoutKit
import SwiftUI
import UILibrary
extension HTTPProxyModule.Builder: ModuleViewProviding {
public func moduleView(with editor: ProfileEditor, impl: ModuleImplementation?) -> some View {
HTTPProxyView(editor: editor, module: self)
public func moduleView(with parameters: ModuleViewParameters) -> some View {
HTTPProxyView(module: self, parameters: parameters)
}
}

View File

@ -23,12 +23,13 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import PassepartoutKit
import SwiftUI
import UILibrary
extension IPModule.Builder: ModuleViewProviding {
public func moduleView(with editor: ProfileEditor, impl: ModuleImplementation?) -> some View {
IPView(editor: editor, module: self)
public func moduleView(with parameters: ModuleViewParameters) -> some View {
IPView(module: self, parameters: parameters)
}
}

View File

@ -28,7 +28,7 @@ import SwiftUI
import UILibrary
extension OnDemandModule.Builder: ModuleViewProviding {
public func moduleView(with editor: ProfileEditor, impl: ModuleImplementation?) -> some View {
OnDemandView(editor: editor, module: self)
public func moduleView(with parameters: ModuleViewParameters) -> some View {
OnDemandView(module: self, parameters: parameters)
}
}

View File

@ -29,8 +29,8 @@ import SwiftUI
import UILibrary
extension OpenVPNModule.Builder: ModuleViewProviding {
public func moduleView(with editor: ProfileEditor, impl: ModuleImplementation?) -> some View {
OpenVPNView(editor: editor, module: self, impl: impl as? OpenVPNModule.Implementation)
public func moduleView(with parameters: ModuleViewParameters) -> some View {
OpenVPNView(module: self, parameters: parameters)
}
}

View File

@ -29,8 +29,8 @@ import SwiftUI
import UILibrary
extension WireGuardModule.Builder: ModuleViewProviding {
public func moduleView(with editor: ProfileEditor, impl: ModuleImplementation?) -> some View {
WireGuardView(editor: editor, module: self, impl: impl as? WireGuardModule.Implementation)
public func moduleView(with parameters: ModuleViewParameters) -> some View {
WireGuardView(module: self, parameters: parameters)
}
}

View File

@ -32,10 +32,15 @@ struct HTTPProxyView: View, ModuleDraftEditing {
@EnvironmentObject
private var theme: Theme
let module: HTTPProxyModule.Builder
@ObservedObject
var editor: ProfileEditor
let module: HTTPProxyModule.Builder
init(module: HTTPProxyModule.Builder, parameters: ModuleViewParameters) {
self.module = module
editor = parameters.editor
}
var body: some View {
Group {

View File

@ -28,15 +28,19 @@ import PassepartoutKit
import SwiftUI
struct IPView: View, ModuleDraftEditing {
let module: IPModule.Builder
@ObservedObject
var editor: ProfileEditor
let module: IPModule.Builder
@State
private var routePresentation: RoutePresentation?
init(module: IPModule.Builder, parameters: ModuleViewParameters) {
self.module = module
editor = parameters.editor
}
var body: some View {
Group {
ipSections(for: .v4)

View File

@ -33,23 +33,23 @@ struct OnDemandView: View, ModuleDraftEditing {
@EnvironmentObject
private var theme: Theme
let module: OnDemandModule.Builder
@ObservedObject
var editor: ProfileEditor
let module: OnDemandModule.Builder
private let wifi: Wifi
@State
private var paywallReason: PaywallReason?
init(
editor: ProfileEditor,
module: OnDemandModule.Builder,
parameters: ModuleViewParameters,
observer: WifiObserver? = nil
) {
self.editor = editor
self.module = module
editor = parameters.editor
wifi = Wifi(observer: observer ?? CoreLocationWifiObserver())
}
@ -239,8 +239,12 @@ private extension OnDemandView {
]
return module.preview {
OnDemandView(
editor: $0,
module: $1,
module: $0,
parameters: .init(
editor: $1,
preferences: nil,
impl: nil
),
observer: MockWifi()
)
}

View File

@ -33,11 +33,11 @@ struct OpenVPNView: View, ModuleDraftEditing {
@Environment(\.navigationPath)
private var path
let module: OpenVPNModule.Builder
@ObservedObject
var editor: ProfileEditor
let module: OpenVPNModule.Builder
let impl: OpenVPNModule.Implementation?
private let isServerPushed: Bool
@ -61,20 +61,18 @@ struct OpenVPNView: View, ModuleDraftEditing {
private var errorHandler: ErrorHandler = .default()
init(serverConfiguration: OpenVPN.Configuration) {
let module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder())
let editor = ProfileEditor(modules: [module])
module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder())
editor = ProfileEditor(modules: [module])
assert(module.configurationBuilder != nil, "isServerPushed must imply module.configurationBuilder != nil")
self.editor = editor
self.module = module
impl = nil
isServerPushed = true
}
init(editor: ProfileEditor, module: OpenVPNModule.Builder, impl: OpenVPNModule.Implementation?) {
self.editor = editor
init(module: OpenVPNModule.Builder, parameters: ModuleViewParameters) {
self.module = module
self.impl = impl
editor = parameters.editor
impl = parameters.impl as? OpenVPNModule.Implementation
isServerPushed = false
}

View File

@ -33,11 +33,11 @@ struct WireGuardView: View, ModuleDraftEditing {
@Environment(\.navigationPath)
private var path
let module: WireGuardModule.Builder
@ObservedObject
var editor: ProfileEditor
let module: WireGuardModule.Builder
let impl: WireGuardModule.Implementation?
@State
@ -46,6 +46,12 @@ struct WireGuardView: View, ModuleDraftEditing {
@State
private var errorHandler: ErrorHandler = .default()
init(module: WireGuardModule.Builder, parameters: ModuleViewParameters) {
self.module = module
editor = parameters.editor
impl = parameters.impl as? WireGuardModule.Implementation
}
var body: some View {
contentView
.moduleView(editor: editor, draft: draft.wrappedValue)

View File

@ -23,16 +23,24 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import PassepartoutKit
import SwiftUI
struct ModuleDetailView: View {
@EnvironmentObject
private var preferencesManager: PreferencesManager
let profileEditor: ProfileEditor
let moduleId: UUID?
let moduleViewFactory: any ModuleViewFactory
@StateObject
private var modulePreferences = ModulePreferences(proxy: nil)
var body: some View {
debugChanges()
return Group {
@ -42,6 +50,16 @@ struct ModuleDetailView: View {
emptyView
}
}
.onLoad {
guard let moduleId else {
return
}
do {
modulePreferences.proxy = try preferencesManager.modulePreferencesProxy(in: moduleId)
} catch {
pp_log(.app, .error, "Unable to load module preferences: \(error)")
}
}
}
}
@ -49,7 +67,11 @@ private extension ModuleDetailView {
@MainActor
func editorView(forModuleWithId moduleId: UUID) -> some View {
AnyView(moduleViewFactory.view(with: profileEditor, moduleId: moduleId))
AnyView(moduleViewFactory.view(
with: profileEditor,
preferences: modulePreferences,
moduleId: moduleId
))
}
var emptyView: some View {

View File

@ -34,6 +34,9 @@ struct VPNProviderServerView<Configuration>: View where Configuration: Identifia
@EnvironmentObject
private var providerManager: ProviderManager
@EnvironmentObject
private var preferencesManager: PreferencesManager
var apis: [APIMapper] = API.shared
let moduleId: UUID
@ -65,10 +68,10 @@ struct VPNProviderServerView<Configuration>: View where Configuration: Identifia
private var onlyShowsFavorites = false
@StateObject
private var filtersViewModel = VPNFiltersView.Model()
private var providerPreferences = ProviderPreferences(proxy: nil)
@StateObject
private var favoritesManager = ProviderFavoritesManager()
private var filtersViewModel = VPNFiltersView.Model()
@StateObject
private var errorHandler: ErrorHandler = .default()
@ -94,7 +97,7 @@ extension VPNProviderServerView {
selectedServer: selectedEntity?.server,
isFiltering: isFiltering,
filtersViewModel: filtersViewModel,
favoritesManager: favoritesManager,
providerPreferences: providerPreferences,
selectTitle: selectTitle,
onSelect: onSelectServer
)
@ -123,7 +126,7 @@ private extension VPNProviderServerView {
var filteredServers: [VPNServer] {
if onlyShowsFavorites {
return servers.filter {
favoritesManager.serverIds.contains($0.serverId)
providerPreferences.favoriteServers.contains($0.serverId)
}
}
return servers
@ -156,7 +159,11 @@ private extension VPNProviderServerView {
private extension VPNProviderServerView {
func loadInitialServers() async {
do {
favoritesManager.moduleId = moduleId
providerPreferences.proxy = try preferencesManager.providerPreferencesProxy(in: providerId)
} catch {
pp_log(.app, .error, "Unable to load preferences for provider \(providerId): \(error)")
}
do {
let repository = try await providerManager.vpnServerRepository(
from: apis,
for: providerId
@ -165,7 +172,7 @@ private extension VPNProviderServerView {
filtersViewModel.load(options: vpnManager.options, initialFilters: initialFilters)
await reloadServers(filters: filtersViewModel.filters)
} catch {
pp_log(.app, .error, "Unable to load VPN repository: \(error)")
pp_log(.app, .error, "Unable to load VPN servers for provider \(providerId): \(error)")
errorHandler.handle(error, title: Strings.Global.Nouns.servers)
}
}
@ -194,7 +201,11 @@ private extension VPNProviderServerView {
}
func onDisappear() {
favoritesManager.save()
do {
try providerPreferences.save()
} catch {
pp_log(.app, .error, "Unable to save preferences: \(error)")
}
}
func onSelectServer(_ server: VPNServer) {

View File

@ -46,7 +46,7 @@ extension VPNProviderServerView {
var filtersViewModel: VPNFiltersView.Model
@ObservedObject
var favoritesManager: ProviderFavoritesManager
var providerPreferences: ProviderPreferences
let selectTitle: String
@ -151,7 +151,7 @@ private extension VPNProviderServerView.ContentView {
Spacer()
FavoriteToggle(
value: server.serverId,
selection: $favoritesManager.serverIds
selection: $providerPreferences.favoriteServers
)
}
}

View File

@ -50,7 +50,7 @@ extension VPNProviderServerView {
var filtersViewModel: VPNFiltersView.Model
@ObservedObject
var favoritesManager: ProviderFavoritesManager
var providerPreferences: ProviderPreferences
let selectTitle: String
@ -87,7 +87,7 @@ private extension VPNProviderServerView.ContentView {
TableColumn("􀋂") { server in
FavoriteToggle(
value: server.serverId,
selection: $favoritesManager.serverIds
selection: $providerPreferences.favoriteServers
)
.environmentObject(theme) // TODO: #873, Table loses environment
}

View File

@ -0,0 +1,77 @@
//
// PreferencesManager.swift
// Passepartout
//
// Created by Davide De Rosa on 12/4/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 PreferencesManager: ObservableObject {
private let modulesRepository: ModulePreferencesRepository
private let providersRepository: ProviderPreferencesRepository
public init(
modulesRepository: ModulePreferencesRepository? = nil,
providersRepository: ProviderPreferencesRepository? = nil
) {
self.modulesRepository = modulesRepository ?? DummyModulePreferencesRepository()
self.providersRepository = providersRepository ?? DummyProviderPreferencesRepository()
}
public func modulePreferencesProxy(in moduleId: UUID) throws -> ModulePreferencesProxy {
try modulesRepository.modulePreferencesProxy(in: moduleId)
}
public func providerPreferencesProxy(in providerId: ProviderID) throws -> ProviderPreferencesProxy {
try providersRepository.providerPreferencesProxy(in: providerId)
}
}
// MARK: - Dummy
private final class DummyModulePreferencesRepository: ModulePreferencesRepository {
private final class Proxy: ModulePreferencesProxy {
func save() throws {
}
}
func modulePreferencesProxy(in moduleId: UUID) throws -> ModulePreferencesProxy {
Proxy()
}
}
private final class DummyProviderPreferencesRepository: ProviderPreferencesRepository {
private final class Proxy: ProviderPreferencesProxy {
var favoriteServers: Set<String> = []
func save() throws {
}
}
func providerPreferencesProxy(in providerId: ProviderID) throws -> ProviderPreferencesProxy {
Proxy()
}
}

View File

@ -30,25 +30,35 @@ import PassepartoutKit
extension BundleConfiguration {
public static var urlForAppLog: URL {
cachesURL.appending(path: Constants.shared.log.appPath)
urlForGroupCaches.appending(path: Constants.shared.log.appPath)
}
public static var urlForTunnelLog: URL {
cachesURL.appending(path: Constants.shared.log.tunnelPath)
urlForGroupCaches.appending(path: Constants.shared.log.tunnelPath)
}
public static var urlForBetaReceipt: URL {
cachesURL.appending(path: Constants.shared.tunnel.betaReceiptPath)
urlForGroupCaches.appending(path: Constants.shared.tunnel.betaReceiptPath)
}
}
extension BundleConfiguration {
public static var urlForGroupCaches: URL {
appGroupURL.appending(components: "Library", "Caches")
}
public static var urlForGroupDocuments: URL {
appGroupURL.appending(components: "Library", "Documents")
}
}
private extension BundleConfiguration {
static var cachesURL: URL {
static var appGroupURL: URL {
let groupId = mainString(for: .groupId)
guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupId) else {
pp_log(.app, .error, "Unable to access App Group container")
return FileManager.default.temporaryDirectory
}
return url.appending(components: "Library", "Caches")
return url
}
}

View File

@ -34,6 +34,8 @@ extension BundleConfiguration {
case cloudKitId
case cloudKitPreferencesId
case userLevel
case groupId

View File

@ -28,12 +28,14 @@ import PassepartoutKit
public struct Constants: Decodable, Sendable {
public struct Containers: Decodable, Sendable {
public let local: String
public let localProfiles: String
public let remote: String
public let remoteProfiles: String
public let providers: String
public let preferences: String
public let legacyV2: String
public let legacyV2TV: String

View File

@ -0,0 +1,49 @@
//
// 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 Foundation
import PassepartoutKit
@MainActor
public final class ModulePreferences: ObservableObject {
public var proxy: ModulePreferencesProxy? {
didSet {
objectWillChange.send()
}
}
public init(proxy: ModulePreferencesProxy?) {
self.proxy = proxy
}
public func save() throws {
try proxy?.save()
}
}
@MainActor
public protocol ModulePreferencesProxy {
func save() throws
}

View File

@ -0,0 +1,61 @@
//
// ProviderPreferences.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
@MainActor
public final class ProviderPreferences: ObservableObject {
public var proxy: ProviderPreferencesProxy? {
didSet {
objectWillChange.send()
}
}
public init(proxy: ProviderPreferencesProxy?) {
self.proxy = proxy
}
public var favoriteServers: Set<String> {
get {
proxy?.favoriteServers ?? []
}
set {
objectWillChange.send()
proxy?.favoriteServers = newValue
}
}
public func save() throws {
try proxy?.save()
}
}
@MainActor
public protocol ProviderPreferencesProxy {
var favoriteServers: Set<String> { get set }
func save() throws
}

View File

@ -1,9 +1,10 @@
{
"bundleKey": "AppConfig",
"containers": {
"local": "Profiles-v3",
"remote": "Profiles-v3.remote",
"localProfiles": "Profiles-v3",
"remoteProfiles": "Profiles-v3.remote",
"providers": "Providers-v3",
"preferences": "Preferences-v3",
"legacyV2": "Profiles",
"legacyV2TV": "SharedProfiles"
},

View File

@ -0,0 +1,31 @@
//
// 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
@MainActor
public protocol ModulePreferencesRepository {
func modulePreferencesProxy(in moduleId: UUID) throws -> ModulePreferencesProxy
}

View File

@ -0,0 +1,32 @@
//
// ProviderPreferencesRepository.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
@MainActor
public protocol ProviderPreferencesRepository {
func providerPreferencesProxy(in providerId: ProviderID) throws -> ProviderPreferencesProxy
}

View File

@ -1,8 +1,8 @@
//
// ProviderFavoriteServers.swift
// UUID+RawRepresentable.swift
// Passepartout
//
// Created by Davide De Rosa on 10/25/24.
// Created by Davide De Rosa on 12/4/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
@ -25,31 +25,12 @@
import Foundation
public struct ProviderFavoriteServers {
private var map: [UUID: Set<String>]
public init() {
map = [:]
}
public func servers(forModuleWithId moduleId: UUID) -> Set<String> {
map[moduleId] ?? []
}
public mutating func setServers(_ servers: Set<String>, forModuleWithId moduleId: UUID) {
map[moduleId] = servers
}
}
extension ProviderFavoriteServers: RawRepresentable {
public var rawValue: String {
(try? JSONEncoder().encode(map))?.base64EncodedString() ?? ""
}
extension UUID: @retroactive RawRepresentable {
public init?(rawValue: String) {
guard let data = Data(base64Encoded: rawValue) else {
return nil
}
map = (try? JSONDecoder().decode([UUID: Set<String>].self, from: data)) ?? [:]
self.init(uuidString: rawValue)
}
public var rawValue: String {
uuidString
}
}

View File

@ -40,6 +40,8 @@ public final class AppContext: ObservableObject {
public let providerManager: ProviderManager
public let preferencesManager: PreferencesManager
public let registry: Registry
public let tunnel: ExtendedTunnel
@ -57,6 +59,7 @@ public final class AppContext: ObservableObject {
migrationManager: MigrationManager,
profileManager: ProfileManager,
providerManager: ProviderManager,
preferencesManager: PreferencesManager,
registry: Registry,
tunnel: ExtendedTunnel,
tunnelReceiptURL: URL
@ -65,6 +68,7 @@ public final class AppContext: ObservableObject {
self.migrationManager = migrationManager
self.profileManager = profileManager
self.providerManager = providerManager
self.preferencesManager = preferencesManager
self.registry = registry
self.tunnel = tunnel
self.tunnelReceiptURL = tunnelReceiptURL

View File

@ -1,64 +0,0 @@
//
// ProviderFavoritesManager.swift
// Passepartout
//
// Created by Davide De Rosa on 10/26/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import Foundation
@MainActor
public final class ProviderFavoritesManager: ObservableObject {
private let defaults: UserDefaults
private var allFavorites: ProviderFavoriteServers
public var moduleId: UUID {
didSet {
guard let rawValue = defaults.string(forKey: UIPreference.providerFavoriteServers.key) else {
allFavorites = ProviderFavoriteServers()
return
}
allFavorites = ProviderFavoriteServers(rawValue: rawValue) ?? ProviderFavoriteServers()
}
}
public var serverIds: Set<String> {
get {
allFavorites.servers(forModuleWithId: moduleId)
}
set {
objectWillChange.send()
allFavorites.setServers(newValue, forModuleWithId: moduleId)
}
}
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
allFavorites = ProviderFavoriteServers()
moduleId = UUID()
}
public func save() {
defaults.set(allFavorites.rawValue, forKey: UIPreference.providerFavoriteServers.key)
}
}

View File

@ -37,8 +37,6 @@ public enum UIPreference: String, PreferenceProtocol {
case profilesLayout
case providerFavoriteServers
public var key: String {
"UI.\(rawValue)"
}

View File

@ -31,16 +31,20 @@ extension ModuleBuilder where Self: ModuleViewProviding {
@MainActor
public func preview(title: String = "") -> some View {
NavigationStack {
moduleView(with: ProfileEditor(modules: [self]), impl: nil)
.navigationTitle(title)
moduleView(with: .init(
editor: ProfileEditor(modules: [self]),
preferences: nil,
impl: nil
))
.navigationTitle(title)
}
.withMockEnvironment()
}
@MainActor
public func preview<C: View>(with content: (ProfileEditor, Self) -> C) -> some View {
public func preview<C: View>(with content: (Self, ProfileEditor) -> C) -> some View {
NavigationStack {
content(ProfileEditor(modules: [self]), self)
content(self, ProfileEditor(modules: [self]))
}
.withMockEnvironment()
}

View File

@ -31,6 +31,7 @@ extension View {
environmentObject(theme)
.environmentObject(context.iapManager)
.environmentObject(context.migrationManager)
.environmentObject(context.preferencesManager)
.environmentObject(context.providerManager)
}

View File

@ -83,6 +83,7 @@ extension AppContext {
migrationManager: migrationManager,
profileManager: profileManager,
providerManager: providerManager,
preferencesManager: PreferencesManager(),
registry: Registry(),
tunnel: tunnel,
tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt

View File

@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import Foundation
import PassepartoutKit
import SwiftUI
@ -35,11 +36,15 @@ public final class DefaultModuleViewFactory: ModuleViewFactory {
}
@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)
if let result {
AnyView(result.provider.moduleView(with: editor, impl: result.impl))
.navigationTitle(result.title)
AnyView(result.provider.moduleView(with: .init(
editor: editor,
preferences: preferences,
impl: result.impl
)))
.navigationTitle(result.title)
}
}
}

View File

@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import Foundation
import SwiftUI
@ -30,5 +31,5 @@ public protocol ModuleViewFactory: AnyObject {
associatedtype Content: View
@MainActor
func view(with editor: ProfileEditor, moduleId: UUID) -> Content
func view(with editor: ProfileEditor, preferences: ModulePreferences, moduleId: UUID) -> Content
}

View File

@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import PassepartoutKit
import SwiftUI
@ -30,5 +31,24 @@ public protocol ModuleViewProviding {
associatedtype Content: View
@MainActor
func moduleView(with editor: ProfileEditor, impl: ModuleImplementation?) -> Content
func moduleView(with parameters: ModuleViewParameters) -> Content
}
public struct ModuleViewParameters {
public let editor: ProfileEditor
public let preferences: ModulePreferences
public let impl: (any ModuleImplementation)?
@MainActor
public init(
editor: ProfileEditor,
preferences: ModulePreferences?,
impl: (any ModuleImplementation)?
) {
self.editor = editor
self.preferences = preferences ?? ModulePreferences(proxy: nil)
self.impl = impl
}
}

View File

@ -9,6 +9,7 @@
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>$(CFG_CLOUDKIT_ID)</string>
<string>$(CFG_CLOUDKIT_PREFERENCES_ID)</string>
<string>$(CFG_LEGACY_V2_CLOUDKIT_ID)</string>
<string>$(CFG_LEGACY_V2_TV_CLOUDKIT_ID)</string>
</array>

View File

@ -8,6 +8,8 @@
<string>$(CFG_APP_STORE_ID)</string>
<key>cloudKitId</key>
<string>$(CFG_CLOUDKIT_ID)</string>
<key>cloudKitPreferencesId</key>
<string>$(CFG_CLOUDKIT_PREFERENCES_ID)</string>
<key>groupId</key>
<string>$(CFG_GROUP_ID)</string>
<key>iapBundlePrefix</key>

View File

@ -29,6 +29,7 @@
CFG_APP_ID = com.algoritmico.ios.Passepartout
CFG_APP_STORE_ID = 1433648537
CFG_CLOUDKIT_ID = iCloud.com.algoritmico.Passepartout.v3
CFG_CLOUDKIT_PREFERENCES_ID = iCloud.com.algoritmico.Passepartout.v3.Preferences
CFG_COPYRIGHT = Copyright © 2024 Davide De Rosa. All rights reserved.
CFG_DISPLAY_NAME = Passepartout
CFG_GROUP_ID[sdk=appletvos*] = $(CFG_RAW_GROUP_ID)

View File

@ -48,7 +48,7 @@ extension AppContext {
let remoteRepositoryBlock: (Bool) -> ProfileRepository = {
let remoteStore = CoreDataPersistentStore(
logger: .default,
containerName: Constants.shared.containers.remote,
containerName: Constants.shared.containers.remoteProfiles,
model: AppData.cdProfilesModel,
cloudKitIdentifier: $0 ? BundleConfiguration.mainString(for: .cloudKitId) : nil,
author: nil
@ -124,6 +124,7 @@ extension AppContext {
migrationManager: migrationManager,
profileManager: profileManager,
providerManager: providerManager,
preferencesManager: .shared,
registry: .shared,
tunnel: tunnel,
tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt
@ -199,7 +200,7 @@ private extension Dependencies.ProfileManager {
static func coreDataProfileRepository(observingResults: Bool) -> ProfileRepository {
let store = CoreDataPersistentStore(
logger: .default,
containerName: Constants.shared.containers.local,
containerName: Constants.shared.containers.localProfiles,
model: AppData.cdProfilesModel,
cloudKitIdentifier: nil,
author: nil
@ -215,21 +216,3 @@ private extension Dependencies.ProfileManager {
}
}
}
// MARK: - Logging
private extension CoreDataPersistentStoreLogger where Self == DefaultCoreDataPersistentStoreLogger {
static var `default`: CoreDataPersistentStoreLogger {
DefaultCoreDataPersistentStoreLogger()
}
}
private struct DefaultCoreDataPersistentStoreLogger: CoreDataPersistentStoreLogger {
func debug(_ msg: String) {
pp_log(.app, .info, msg)
}
func warning(_ msg: String) {
pp_log(.app, .error, msg)
}
}

View File

@ -23,6 +23,8 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import AppData
import AppDataPreferences
import CommonLibrary
import CommonUtils
import CPassepartoutOpenVPNOpenSSL
@ -30,8 +32,6 @@ import Foundation
import PassepartoutKit
import PassepartoutWireGuardGo
// MARK: Registry
extension Registry {
static let shared = Registry(
withKnownHandlers: true,
@ -130,3 +130,40 @@ extension InAppProcessor {
)
}
}
extension PreferencesManager {
static let shared: PreferencesManager = {
let preferencesStore = CoreDataPersistentStore(
logger: .default,
containerName: Constants.shared.containers.preferences,
baseURL: BundleConfiguration.urlForGroupDocuments,
model: AppData.cdPreferencesModel,
cloudKitIdentifier: BundleConfiguration.mainString(for: .cloudKitPreferencesId),
author: nil
)
let modulePreferencesRepository = AppData.cdModulePreferencesRepositoryV3(context: preferencesStore.context)
let providerPreferencesRepository = AppData.cdProviderPreferencesRepositoryV3(context: preferencesStore.context)
return PreferencesManager(
modulesRepository: modulePreferencesRepository,
providersRepository: providerPreferencesRepository
)
}()
}
// MARK: - Logging
extension CoreDataPersistentStoreLogger where Self == DefaultCoreDataPersistentStoreLogger {
static var `default`: CoreDataPersistentStoreLogger {
DefaultCoreDataPersistentStoreLogger()
}
}
struct DefaultCoreDataPersistentStoreLogger: CoreDataPersistentStoreLogger {
func debug(_ msg: String) {
pp_log(.app, .info, msg)
}
func warning(_ msg: String) {
pp_log(.app, .error, msg)
}
}

View File

@ -65,6 +65,7 @@ extension AppContext {
migrationManager: migrationManager,
profileManager: profileManager,
providerManager: providerManager,
preferencesManager: PreferencesManager(),
registry: registry,
tunnel: tunnel,
tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt

View File

@ -10,6 +10,8 @@
<string>$(CFG_KEYCHAIN_GROUP_ID)</string>
<key>tunnelId</key>
<string>$(CFG_TUNNEL_ID)</string>
<key>cloudKitPreferencesId</key>
<string>$(CFG_CLOUDKIT_PREFERENCES_ID)</string>
</dict>
<key>NSExtension</key>
<dict>