Per-profile iCloud syncing (#668)
Keep two separate stores to accomplish per-profile sharing: - Local store, where to push updates manually (save/remove/search) - Remote iCloud store, where to pull updates from A profile can be added/removed to/from the iCloud store so that other devices can push/pull updates to it. Consequently, updates to the iCloud store will NEVER cause a profile deletion. Once removed, the profile will stay locally. Fixes #586 Fixes #555
This commit is contained in:
parent
1227df60ff
commit
1491766102
|
@ -2,26 +2,39 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>$(CFG_CLOUDKIT_ID)</string>
|
||||||
|
<string>$(CFG_LEGACY_V2_CLOUDKIT_ID)</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.icloud-services</key>
|
||||||
|
<array>
|
||||||
|
<string>CloudKit</string>
|
||||||
|
</array>
|
||||||
<key>com.apple.developer.networking.networkextension</key>
|
<key>com.apple.developer.networking.networkextension</key>
|
||||||
<array>
|
<array>
|
||||||
<string>packet-tunnel-provider</string>
|
<string>packet-tunnel-provider</string>
|
||||||
</array>
|
</array>
|
||||||
<key>com.apple.developer.networking.wifi-info</key>
|
<key>com.apple.developer.networking.wifi-info</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(CFG_GROUP_ID)</string>
|
<string>$(CFG_GROUP_ID)</string>
|
||||||
</array>
|
</array>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.files.user-selected.read-only</key>
|
<key>com.apple.security.files.user-selected.read-only</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.personal-information.location</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.server</key>
|
<key>com.apple.security.network.server</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.personal-information.location</key>
|
||||||
|
<true/>
|
||||||
<key>keychain-access-groups</key>
|
<key>keychain-access-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(AppIdentifierPrefix)$(CFG_GROUP_ID)</string>
|
<string>$(AppIdentifierPrefix)$(CFG_GROUP_ID)</string>
|
||||||
|
|
|
@ -6,6 +6,8 @@
|
||||||
<dict>
|
<dict>
|
||||||
<key>appStoreId</key>
|
<key>appStoreId</key>
|
||||||
<string>$(CFG_APP_STORE_ID)</string>
|
<string>$(CFG_APP_STORE_ID)</string>
|
||||||
|
<key>cloudKitId</key>
|
||||||
|
<string>$(CFG_CLOUDKIT_ID)</string>
|
||||||
<key>groupId</key>
|
<key>groupId</key>
|
||||||
<string>$(CFG_GROUP_ID)</string>
|
<string>$(CFG_GROUP_ID)</string>
|
||||||
<key>iapBundlePrefix</key>
|
<key>iapBundlePrefix</key>
|
||||||
|
@ -14,8 +16,14 @@
|
||||||
<string>$(CFG_TEAM_ID).$(CFG_GROUP_ID)</string>
|
<string>$(CFG_TEAM_ID).$(CFG_GROUP_ID)</string>
|
||||||
<key>profilesContainerName</key>
|
<key>profilesContainerName</key>
|
||||||
<string>$(CFG_PROFILES_CONTAINER_NAME)</string>
|
<string>$(CFG_PROFILES_CONTAINER_NAME)</string>
|
||||||
|
<key>remoteProfilesContainerName</key>
|
||||||
|
<string>$(CFG_PROFILES_CONTAINER_NAME).remote</string>
|
||||||
<key>tunnelId</key>
|
<key>tunnelId</key>
|
||||||
<string>$(CFG_TUNNEL_ID)</string>
|
<string>$(CFG_TUNNEL_ID)</string>
|
||||||
|
<key>legacyV2CloudKitId</key>
|
||||||
|
<string>$(CFG_LEGACY_V2_CLOUDKIT_ID)</string>
|
||||||
|
<key>legacyV2ProfilesContainerName</key>
|
||||||
|
<string>$(CFG_LEGACY_V2_PROFILES_CONTAINER_NAME)</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>CFBundleDocumentTypes</key>
|
<key>CFBundleDocumentTypes</key>
|
||||||
<array>
|
<array>
|
||||||
|
@ -52,5 +60,9 @@
|
||||||
<array>
|
<array>
|
||||||
<string>CustomIntentIntent</string>
|
<string>CustomIntentIntent</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>remote-notification</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
@ -28,6 +28,7 @@
|
||||||
|
|
||||||
CFG_APP_ID = com.algoritmico.ios.Passepartout
|
CFG_APP_ID = com.algoritmico.ios.Passepartout
|
||||||
CFG_APP_STORE_ID = 1433648537
|
CFG_APP_STORE_ID = 1433648537
|
||||||
|
CFG_CLOUDKIT_ID = iCloud.com.algoritmico.Passepartout.v3
|
||||||
CFG_COPYRIGHT = Copyright © 2024 Davide De Rosa. All rights reserved.
|
CFG_COPYRIGHT = Copyright © 2024 Davide De Rosa. All rights reserved.
|
||||||
CFG_DISPLAY_NAME = Passepartout
|
CFG_DISPLAY_NAME = Passepartout
|
||||||
CFG_GROUP_ID[sdk=appletvos*] = $(CFG_RAW_GROUP_ID)
|
CFG_GROUP_ID[sdk=appletvos*] = $(CFG_RAW_GROUP_ID)
|
||||||
|
@ -42,6 +43,9 @@ CFG_RAW_GROUP_ID = group.com.algoritmico.Passepartout
|
||||||
CFG_TEAM_ID = DTDYD63ZX9
|
CFG_TEAM_ID = DTDYD63ZX9
|
||||||
CFG_TUNNEL_ID = $(CFG_APP_ID).Tunnel
|
CFG_TUNNEL_ID = $(CFG_APP_ID).Tunnel
|
||||||
|
|
||||||
|
CFG_LEGACY_V2_CLOUDKIT_ID = iCloud.com.algoritmico.Passepartout
|
||||||
|
CFG_LEGACY_V2_PROFILES_CONTAINER_NAME = Profiles
|
||||||
|
|
||||||
PATH = $(PATH):/opt/homebrew/bin:/usr/local/bin:/usr/local/go/bin
|
PATH = $(PATH):/opt/homebrew/bin:/usr/local/bin:/usr/local/go/bin
|
||||||
CUSTOM_SCRIPT_PATH = $(PATH)
|
CUSTOM_SCRIPT_PATH = $(PATH)
|
||||||
|
|
||||||
|
|
|
@ -1,67 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "1540"
|
|
||||||
version = "1.7">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES"
|
|
||||||
buildArchitectures = "Automatic">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "IntentsLibrary"
|
|
||||||
BuildableName = "IntentsLibrary"
|
|
||||||
BlueprintName = "IntentsLibrary"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
shouldAutocreateTestPlan = "YES">
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
<MacroExpansion>
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "IntentsLibrary"
|
|
||||||
BuildableName = "IntentsLibrary"
|
|
||||||
BlueprintName = "IntentsLibrary"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</MacroExpansion>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
|
@ -24,13 +24,6 @@ let package = Package(
|
||||||
"AppUI"
|
"AppUI"
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.library(
|
|
||||||
name: "IntentsLibrary",
|
|
||||||
targets: [
|
|
||||||
"AppDataProfiles",
|
|
||||||
"AppLibrary"
|
|
||||||
]
|
|
||||||
),
|
|
||||||
.library(
|
.library(
|
||||||
name: "TunnelLibrary",
|
name: "TunnelLibrary",
|
||||||
targets: ["CommonLibrary"]
|
targets: ["CommonLibrary"]
|
||||||
|
@ -72,6 +65,7 @@ let package = Package(
|
||||||
"AppData",
|
"AppData",
|
||||||
"CommonLibrary",
|
"CommonLibrary",
|
||||||
"Kvitto",
|
"Kvitto",
|
||||||
|
"LegacyV2",
|
||||||
"UtilsLibrary"
|
"UtilsLibrary"
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
@ -93,6 +87,15 @@ let package = Package(
|
||||||
.process("Resources")
|
.process("Resources")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
.target(
|
||||||
|
name: "LegacyV2",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "PassepartoutKit", package: "passepartoutkit")
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
.process("Profiles.xcdatamodeld")
|
||||||
|
]
|
||||||
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "UtilsLibrary"
|
name: "UtilsLibrary"
|
||||||
),
|
),
|
||||||
|
|
|
@ -28,12 +28,10 @@ import CoreData
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension AppData {
|
extension AppData {
|
||||||
|
public static var cdProfilesModel: NSManagedObjectModel {
|
||||||
@MainActor
|
|
||||||
public static let cdProfilesModel: NSManagedObjectModel = {
|
|
||||||
guard let model: NSManagedObjectModel = .mergedModel(from: [.module]) else {
|
guard let model: NSManagedObjectModel = .mergedModel(from: [.module]) else {
|
||||||
fatalError("Unable to build Core Data model")
|
fatalError("Unable to build Core Data model (Profiles v3)")
|
||||||
}
|
}
|
||||||
return model
|
return model
|
||||||
}()
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// CDProfileRepository.swift
|
// CDProfileRepositoryV3.swift
|
||||||
// Passepartout
|
// Passepartout
|
||||||
//
|
//
|
||||||
// Created by Davide De Rosa on 8/11/24.
|
// Created by Davide De Rosa on 8/11/24.
|
||||||
|
@ -30,14 +30,15 @@ import PassepartoutKit
|
||||||
import UtilsLibrary
|
import UtilsLibrary
|
||||||
|
|
||||||
extension AppData {
|
extension AppData {
|
||||||
|
|
||||||
// TODO: #656, make non-static
|
// TODO: #656, make non-static
|
||||||
public static func cdProfileRepository(
|
public static func cdProfileRepositoryV3(
|
||||||
registry: Registry,
|
registry: Registry,
|
||||||
coder: ProfileCoder,
|
coder: ProfileCoder,
|
||||||
context: NSManagedObjectContext,
|
context: NSManagedObjectContext,
|
||||||
onResultError: ((Error) -> CoreDataResultAction)?
|
onResultError: ((Error) -> CoreDataResultAction)?
|
||||||
) -> any ProfileRepository {
|
) -> any ProfileRepository {
|
||||||
let repository = CoreDataRepository<CDProfile, Profile>(context: context) {
|
let repository = CoreDataRepository<CDProfileV3, Profile>(context: context) {
|
||||||
$0.sortDescriptors = [
|
$0.sortDescriptors = [
|
||||||
.init(key: "name", ascending: true, selector: #selector(NSString.caseInsensitiveCompare)),
|
.init(key: "name", ascending: true, selector: #selector(NSString.caseInsensitiveCompare)),
|
||||||
.init(key: "lastUpdate", ascending: true)
|
.init(key: "lastUpdate", ascending: true)
|
||||||
|
@ -50,7 +51,7 @@ extension AppData {
|
||||||
} toMapper: {
|
} toMapper: {
|
||||||
let encoded = try registry.encodedProfile($0, with: coder)
|
let encoded = try registry.encodedProfile($0, with: coder)
|
||||||
|
|
||||||
let cdProfile = CDProfile(context: $1)
|
let cdProfile = CDProfileV3(context: $1)
|
||||||
cdProfile.uuid = $0.id
|
cdProfile.uuid = $0.id
|
||||||
cdProfile.name = $0.name
|
cdProfile.name = $0.name
|
||||||
cdProfile.encoded = encoded
|
cdProfile.encoded = encoded
|
||||||
|
@ -64,7 +65,7 @@ extension AppData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension CDProfile: CoreDataUniqueEntity {
|
extension CDProfileV3: CoreDataUniqueEntity {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension CoreDataRepository: ProfileRepository where T == Profile {
|
extension CoreDataRepository: ProfileRepository where T == Profile {
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// CDProfile.swift
|
// CDProfileV3.swift
|
||||||
// Passepartout
|
// Passepartout
|
||||||
//
|
//
|
||||||
// Created by Davide De Rosa on 8/11/24.
|
// Created by Davide De Rosa on 8/11/24.
|
||||||
|
@ -26,8 +26,8 @@
|
||||||
import CoreData
|
import CoreData
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@objc(CDProfile)
|
@objc(CDProfileV3)
|
||||||
final class CDProfile: NSManagedObject {
|
final class CDProfileV3: NSManagedObject {
|
||||||
@NSManaged var uuid: UUID?
|
@NSManaged var uuid: UUID?
|
||||||
@NSManaged var name: String?
|
@NSManaged var name: String?
|
||||||
@NSManaged var encoded: String?
|
@NSManaged var encoded: String?
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>_XCCurrentVersionName</key>
|
||||||
|
<string>ProfilesV3.xcdatamodel</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23G93" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
|
||||||
<entity name="CDProfile" representedClassName="CDProfile" syncable="YES">
|
<entity name="CDProfile" representedClassName="CDProfile" elementID="CDProfile" versionHashModifier="1" syncable="YES">
|
||||||
<attribute name="encoded" optional="YES" attributeType="String" allowsCloudEncryption="YES"/>
|
<attribute name="encoded" optional="YES" attributeType="String" allowsCloudEncryption="YES"/>
|
||||||
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<attribute name="name" optional="YES" attributeType="String"/>
|
<attribute name="name" optional="YES" attributeType="String"/>
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23G93" 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="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="name" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="uuid" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
</entity>
|
||||||
|
</model>
|
|
@ -35,22 +35,28 @@ public final class ProfileManager: ObservableObject {
|
||||||
case save(Profile)
|
case save(Profile)
|
||||||
|
|
||||||
case remove([Profile.ID])
|
case remove([Profile.ID])
|
||||||
|
|
||||||
case update([Profile])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public var beforeSave: ((Profile) async throws -> Void)?
|
public var beforeSave: ((Profile) async throws -> Void)?
|
||||||
|
|
||||||
public var afterRemove: (([Profile.ID]) async -> Void)?
|
public var afterRemove: (([Profile.ID]) async -> Void)?
|
||||||
|
|
||||||
public let didChange: PassthroughSubject<Event, Never>
|
private let repository: any ProfileRepository
|
||||||
|
|
||||||
|
private let remoteRepository: (any ProfileRepository)?
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
private var profiles: [Profile]
|
private var profiles: [Profile]
|
||||||
|
|
||||||
private var allProfileIds: Set<Profile.ID>
|
private var allProfiles: [Profile.ID: Profile] {
|
||||||
|
didSet {
|
||||||
|
reloadFilteredProfiles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private let repository: any ProfileRepository
|
private var allRemoteProfiles: [Profile.ID: Profile]
|
||||||
|
|
||||||
|
public let didChange: PassthroughSubject<Event, Never>
|
||||||
|
|
||||||
private let searchSubject: CurrentValueSubject<String, Never>
|
private let searchSubject: CurrentValueSubject<String, Never>
|
||||||
|
|
||||||
|
@ -58,21 +64,27 @@ public final class ProfileManager: ObservableObject {
|
||||||
|
|
||||||
// for testing/previews
|
// for testing/previews
|
||||||
public init(profiles: [Profile]) {
|
public init(profiles: [Profile]) {
|
||||||
didChange = PassthroughSubject()
|
|
||||||
self.profiles = profiles.sorted {
|
|
||||||
$0.name.lowercased() < $1.name.lowercased()
|
|
||||||
}
|
|
||||||
allProfileIds = []
|
|
||||||
repository = MockProfileRepository(profiles: profiles)
|
repository = MockProfileRepository(profiles: profiles)
|
||||||
|
remoteRepository = nil
|
||||||
|
self.profiles = []
|
||||||
|
allProfiles = profiles.reduce(into: [:]) {
|
||||||
|
$0[$1.id] = $1
|
||||||
|
}
|
||||||
|
allRemoteProfiles = [:]
|
||||||
|
|
||||||
|
didChange = PassthroughSubject()
|
||||||
searchSubject = CurrentValueSubject("")
|
searchSubject = CurrentValueSubject("")
|
||||||
subscriptions = []
|
subscriptions = []
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(repository: any ProfileRepository) {
|
public init(repository: any ProfileRepository, remoteRepository: (any ProfileRepository)?) {
|
||||||
didChange = PassthroughSubject()
|
|
||||||
profiles = []
|
|
||||||
allProfileIds = []
|
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
|
self.remoteRepository = remoteRepository
|
||||||
|
profiles = []
|
||||||
|
allProfiles = [:]
|
||||||
|
allRemoteProfiles = [:]
|
||||||
|
|
||||||
|
didChange = PassthroughSubject()
|
||||||
searchSubject = CurrentValueSubject("")
|
searchSubject = CurrentValueSubject("")
|
||||||
subscriptions = []
|
subscriptions = []
|
||||||
}
|
}
|
||||||
|
@ -109,6 +121,7 @@ extension ProfileManager {
|
||||||
do {
|
do {
|
||||||
try await beforeSave?(profile)
|
try await beforeSave?(profile)
|
||||||
try await repository.saveEntities([profile])
|
try await repository.saveEntities([profile])
|
||||||
|
allProfiles[profile.id] = profile
|
||||||
didChange.send(.save(profile))
|
didChange.send(.save(profile))
|
||||||
} catch {
|
} catch {
|
||||||
pp_log(.app, .fault, "Unable to save profile \(profile.id): \(error)")
|
pp_log(.app, .fault, "Unable to save profile \(profile.id): \(error)")
|
||||||
|
@ -122,9 +135,13 @@ extension ProfileManager {
|
||||||
|
|
||||||
public func remove(withIds profileIds: [Profile.ID]) async {
|
public func remove(withIds profileIds: [Profile.ID]) async {
|
||||||
do {
|
do {
|
||||||
allProfileIds.subtract(profileIds)
|
var newAllProfiles = allProfiles
|
||||||
try await repository.removeEntities(withIds: profileIds)
|
try await repository.removeEntities(withIds: profileIds)
|
||||||
|
profileIds.forEach {
|
||||||
|
newAllProfiles.removeValue(forKey: $0)
|
||||||
|
}
|
||||||
await afterRemove?(profileIds)
|
await afterRemove?(profileIds)
|
||||||
|
allProfiles = newAllProfiles
|
||||||
didChange.send(.remove(profileIds))
|
didChange.send(.remove(profileIds))
|
||||||
} catch {
|
} catch {
|
||||||
pp_log(.app, .fault, "Unable to remove profiles \(profileIds): \(error)")
|
pp_log(.app, .fault, "Unable to remove profiles \(profileIds): \(error)")
|
||||||
|
@ -132,7 +149,30 @@ extension ProfileManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func exists(withId profileId: Profile.ID) -> Bool {
|
public func exists(withId profileId: Profile.ID) -> Bool {
|
||||||
allProfileIds.contains(profileId)
|
allProfiles.keys.contains(profileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Remote
|
||||||
|
|
||||||
|
extension ProfileManager {
|
||||||
|
public func isRemotelyShared(profileWithId profileId: Profile.ID) -> Bool {
|
||||||
|
allRemoteProfiles.keys.contains(profileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setRemotelyShared(_ shared: Bool, profileWithId profileId: Profile.ID) async throws {
|
||||||
|
guard let remoteRepository else {
|
||||||
|
pp_log(.app, .error, "Unable to share remotely when no remoteRepository is set")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let profile = allProfiles[profileId] else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if shared {
|
||||||
|
try await remoteRepository.saveEntities([profile])
|
||||||
|
} else {
|
||||||
|
try await remoteRepository.removeEntities(withIds: [profileId])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -183,9 +223,18 @@ extension ProfileManager {
|
||||||
public func observeObjects(searchDebounce: Int = 200) {
|
public func observeObjects(searchDebounce: Int = 200) {
|
||||||
repository
|
repository
|
||||||
.entitiesPublisher
|
.entitiesPublisher
|
||||||
|
.first()
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] in
|
.sink { [weak self] in
|
||||||
self?.notifyUpdatedEntities($0)
|
self?.notifyLocalEntities($0)
|
||||||
|
}
|
||||||
|
.store(in: &subscriptions)
|
||||||
|
|
||||||
|
remoteRepository?
|
||||||
|
.entitiesPublisher
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] in
|
||||||
|
self?.notifyRemoteEntities($0)
|
||||||
}
|
}
|
||||||
.store(in: &subscriptions)
|
.store(in: &subscriptions)
|
||||||
|
|
||||||
|
@ -199,29 +248,45 @@ extension ProfileManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ProfileManager {
|
private extension ProfileManager {
|
||||||
func notifyUpdatedEntities(_ result: EntitiesResult<Profile>) {
|
func notifyLocalEntities(_ result: EntitiesResult<Profile>) {
|
||||||
let oldProfiles = profiles.reduce(into: [:]) {
|
allProfiles = result.entities.reduce(into: [:]) {
|
||||||
$0[$1.id] = $1
|
$0[$1.id] = $1
|
||||||
}
|
}
|
||||||
let newProfiles = result.entities
|
}
|
||||||
let updatedProfiles = newProfiles.filter {
|
|
||||||
$0 != oldProfiles[$0.id] // includes new profiles
|
func notifyRemoteEntities(_ result: EntitiesResult<Profile>) {
|
||||||
|
allRemoteProfiles = result.entities.reduce(into: [:]) {
|
||||||
|
$0[$1.id] = $1
|
||||||
}
|
}
|
||||||
|
|
||||||
if !result.isFiltering {
|
// pull remote updates into local profiles (best-effort)
|
||||||
allProfileIds = Set(newProfiles.map(\.id))
|
for remoteProfile in allRemoteProfiles.values {
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
do {
|
||||||
|
pp_log(.app, .notice, "Import remote profile \(remoteProfile.id)...")
|
||||||
|
try await self?.save(remoteProfile)
|
||||||
|
} catch {
|
||||||
|
pp_log(.app, .error, "Unable to import remote profile: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
profiles = newProfiles
|
|
||||||
didChange.send(.update(updatedProfiles))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func performSearch(_ search: String) {
|
func performSearch(_ search: String) {
|
||||||
Task {
|
reloadFilteredProfiles(with: search)
|
||||||
guard !search.isEmpty else {
|
}
|
||||||
try await repository.resetFilter()
|
|
||||||
return
|
func reloadFilteredProfiles(with search: String? = nil) {
|
||||||
|
profiles = allProfiles
|
||||||
|
.values
|
||||||
|
.filter {
|
||||||
|
if let search, !search.isEmpty {
|
||||||
|
return $0.name.lowercased().contains(search.lowercased())
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
.sorted {
|
||||||
|
$0.name.lowercased() < $1.name.lowercased()
|
||||||
}
|
}
|
||||||
try await repository.filter(byName: search)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,6 +36,9 @@ final class ProfileEditor: ObservableObject {
|
||||||
@Published
|
@Published
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
|
@Published
|
||||||
|
var isShared: Bool
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
private(set) var modules: [any EditableModule]
|
private(set) var modules: [any EditableModule]
|
||||||
|
|
||||||
|
@ -58,6 +61,7 @@ final class ProfileEditor: ObservableObject {
|
||||||
activeModulesIds = Set(modules.map(\.id))
|
activeModulesIds = Set(modules.map(\.id))
|
||||||
moduleNames = [:]
|
moduleNames = [:]
|
||||||
removedModules = [:]
|
removedModules = [:]
|
||||||
|
isShared = false
|
||||||
}
|
}
|
||||||
|
|
||||||
init(profile: Profile) {
|
init(profile: Profile) {
|
||||||
|
@ -67,15 +71,17 @@ final class ProfileEditor: ObservableObject {
|
||||||
activeModulesIds = profile.activeModulesIds
|
activeModulesIds = profile.activeModulesIds
|
||||||
moduleNames = profile.moduleNames
|
moduleNames = profile.moduleNames
|
||||||
removedModules = [:]
|
removedModules = [:]
|
||||||
|
isShared = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func editProfile(_ profile: Profile) {
|
func editProfile(_ profile: Profile, isShared: Bool) {
|
||||||
id = profile.id
|
id = profile.id
|
||||||
name = profile.name
|
name = profile.name
|
||||||
modules = profile.modulesBuilders
|
modules = profile.modulesBuilders
|
||||||
activeModulesIds = profile.activeModulesIds
|
activeModulesIds = profile.activeModulesIds
|
||||||
moduleNames = profile.moduleNames
|
moduleNames = profile.moduleNames
|
||||||
removedModules = [:]
|
removedModules = [:]
|
||||||
|
self.isShared = isShared
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -267,6 +273,7 @@ extension ProfileEditor {
|
||||||
do {
|
do {
|
||||||
let newProfile = try build()
|
let newProfile = try build()
|
||||||
try await profileManager.save(newProfile)
|
try await profileManager.save(newProfile)
|
||||||
|
try await profileManager.setRemotelyShared(isShared, profileWithId: newProfile.id)
|
||||||
} catch {
|
} catch {
|
||||||
pp_log(.app, .fault, "Unable to save edited profile: \(error)")
|
pp_log(.app, .fault, "Unable to save edited profile: \(error)")
|
||||||
throw error
|
throw error
|
||||||
|
|
|
@ -285,6 +285,22 @@ public enum Strings {
|
||||||
public static let add = Strings.tr("Localizable", "modules.dns.servers.add", fallback: "Add address")
|
public static let add = Strings.tr("Localizable", "modules.dns.servers.add", fallback: "Add address")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public enum General {
|
||||||
|
public enum Sections {
|
||||||
|
public enum Storage {
|
||||||
|
/// Profiles are stored to iCloud encrypted.
|
||||||
|
public static let footer = Strings.tr("Localizable", "modules.general.sections.storage.footer", fallback: "Profiles are stored to iCloud encrypted.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public enum Storage {
|
||||||
|
/// Share on iCloud
|
||||||
|
public static let shared = Strings.tr("Localizable", "modules.general.storage.shared", fallback: "Share on iCloud")
|
||||||
|
public enum Shared {
|
||||||
|
/// Share on iCloud
|
||||||
|
public static let purchase = Strings.tr("Localizable", "modules.general.storage.shared.purchase", fallback: "Share on iCloud")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
public enum HttpProxy {
|
public enum HttpProxy {
|
||||||
public enum BypassDomains {
|
public enum BypassDomains {
|
||||||
/// Add bypass domain
|
/// Add bypass domain
|
||||||
|
|
|
@ -41,6 +41,7 @@ extension AppContext {
|
||||||
iapManager: IAPManager(
|
iapManager: IAPManager(
|
||||||
customUserLevel: nil,
|
customUserLevel: nil,
|
||||||
receiptReader: MockReceiptReader(),
|
receiptReader: MockReceiptReader(),
|
||||||
|
unrestrictedFeatures: [.sharing],
|
||||||
productsAtBuild: { _ in
|
productsAtBuild: { _ in
|
||||||
[]
|
[]
|
||||||
}
|
}
|
||||||
|
|
|
@ -159,6 +159,10 @@
|
||||||
|
|
||||||
// MARK: - Module views
|
// MARK: - Module views
|
||||||
|
|
||||||
|
"modules.general.sections.storage.footer" = "Profiles are stored to iCloud encrypted.";
|
||||||
|
"modules.general.storage.shared" = "Share on iCloud";
|
||||||
|
"modules.general.storage.shared.purchase" = "Share on iCloud";
|
||||||
|
|
||||||
"modules.dns.servers.add" = "Add address";
|
"modules.dns.servers.add" = "Add address";
|
||||||
"modules.dns.search_domains.add" = "Add domain";
|
"modules.dns.search_domains.add" = "Add domain";
|
||||||
"modules.http_proxy.bypass_domains.add" = "Add bypass domain";
|
"modules.http_proxy.bypass_domains.add" = "Add bypass domain";
|
||||||
|
|
|
@ -140,7 +140,7 @@ private extension AppInlineCoordinator {
|
||||||
}
|
}
|
||||||
|
|
||||||
func enterDetail(of profile: Profile) {
|
func enterDetail(of profile: Profile) {
|
||||||
profileEditor.editProfile(profile)
|
profileEditor.editProfile(profile, isShared: profileManager.isRemotelyShared(profileWithId: profile.id))
|
||||||
push(.editProfile)
|
push(.editProfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -132,7 +132,7 @@ extension AppModalCoordinator {
|
||||||
|
|
||||||
func enterDetail(of profile: Profile) {
|
func enterDetail(of profile: Profile) {
|
||||||
profilePath = NavigationPath()
|
profilePath = NavigationPath()
|
||||||
profileEditor.editProfile(profile)
|
profileEditor.editProfile(profile, isShared: profileManager.isRemotelyShared(profileWithId: profile.id))
|
||||||
modalRoute = .editProfile
|
modalRoute = .editProfile
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -64,7 +64,7 @@ struct ProfileEditView: View, Routable {
|
||||||
Text(Strings.Views.Profile.ModuleList.Section.footer)
|
Text(Strings.Views.Profile.ModuleList.Section.footer)
|
||||||
}
|
}
|
||||||
StorageSection(
|
StorageSection(
|
||||||
uuid: profileEditor.id
|
profileEditor: profileEditor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.toolbar(content: toolbarContent)
|
.toolbar(content: toolbarContent)
|
||||||
|
|
|
@ -39,7 +39,7 @@ struct ProfileGeneralView: View {
|
||||||
placeholder: Strings.Placeholders.Profile.name
|
placeholder: Strings.Placeholders.Profile.name
|
||||||
)
|
)
|
||||||
StorageSection(
|
StorageSection(
|
||||||
uuid: profileEditor.id
|
profileEditor: profileEditor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.themeForm()
|
.themeForm()
|
||||||
|
|
|
@ -29,6 +29,7 @@ extension Theme {
|
||||||
public enum ImageName {
|
public enum ImageName {
|
||||||
case add
|
case add
|
||||||
case close
|
case close
|
||||||
|
case cloud
|
||||||
case contextDuplicate
|
case contextDuplicate
|
||||||
case contextRemove
|
case contextRemove
|
||||||
case copy
|
case copy
|
||||||
|
|
|
@ -80,6 +80,7 @@ public final class Theme: ObservableObject {
|
||||||
switch $0 {
|
switch $0 {
|
||||||
case .add: return "plus"
|
case .add: return "plus"
|
||||||
case .close: return "xmark"
|
case .close: return "xmark"
|
||||||
|
case .cloud: return "cloud"
|
||||||
case .contextDuplicate: return "plus.square.on.square"
|
case .contextDuplicate: return "plus.square.on.square"
|
||||||
case .contextRemove: return "trash"
|
case .contextRemove: return "trash"
|
||||||
case .copy: return "doc.on.doc"
|
case .copy: return "doc.on.doc"
|
||||||
|
|
|
@ -37,18 +37,30 @@ struct ProfileCardView: View {
|
||||||
|
|
||||||
let header: ProfileHeader
|
let header: ProfileHeader
|
||||||
|
|
||||||
|
let isShared: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch style {
|
switch style {
|
||||||
case .compact:
|
case .compact:
|
||||||
Text(header.name)
|
HStack {
|
||||||
.themeTruncating()
|
Text(header.name)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.themeTruncating()
|
||||||
|
if isShared {
|
||||||
|
ThemeImage(.cloud)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
case .full:
|
case .full:
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(header.name)
|
HStack {
|
||||||
.font(.headline)
|
Text(header.name)
|
||||||
.themeTruncating()
|
.font(.headline)
|
||||||
|
.themeTruncating()
|
||||||
|
if isShared {
|
||||||
|
ThemeImage(.cloud)
|
||||||
|
}
|
||||||
|
}
|
||||||
Text(Strings.Views.Profiles.Rows.modules(header.modules.count))
|
Text(Strings.Views.Profiles.Rows.modules(header.modules.count))
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
@ -63,10 +75,19 @@ struct ProfileCardView: View {
|
||||||
#Preview {
|
#Preview {
|
||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
ProfileCardView(style: .compact, header: Profile.mock.header())
|
ProfileCardView(
|
||||||
|
style: .compact,
|
||||||
|
header: Profile.mock.header(),
|
||||||
|
isShared: true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
ProfileCardView(style: .full, header: Profile.mock.header())
|
ProfileCardView(
|
||||||
|
style: .full,
|
||||||
|
header: Profile.mock.header(),
|
||||||
|
isShared: true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.withMockEnvironment()
|
||||||
}
|
}
|
||||||
|
|
|
@ -83,9 +83,13 @@ private extension ProfileRowView {
|
||||||
interactiveManager: interactiveManager,
|
interactiveManager: interactiveManager,
|
||||||
errorHandler: errorHandler
|
errorHandler: errorHandler
|
||||||
) { _ in
|
) { _ in
|
||||||
ProfileCardView(style: style, header: header)
|
ProfileCardView(
|
||||||
.frame(maxWidth: .infinity)
|
style: style,
|
||||||
.contentShape(.rect)
|
header: header,
|
||||||
|
isShared: profileManager.isRemotelyShared(profileWithId: header.id)
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.contentShape(.rect)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -27,29 +27,58 @@ import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct StorageSection: View {
|
struct StorageSection: View {
|
||||||
let uuid: UUID
|
|
||||||
|
@EnvironmentObject
|
||||||
|
private var iapManager: IAPManager
|
||||||
|
|
||||||
|
@ObservedObject
|
||||||
|
var profileEditor: ProfileEditor
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var paywallReason: PaywallReason?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
#if DEBUG
|
|
||||||
debugChanges()
|
debugChanges()
|
||||||
return Section {
|
return Group {
|
||||||
|
sharingToggle
|
||||||
|
#if DEBUG
|
||||||
ThemeCopiableText(
|
ThemeCopiableText(
|
||||||
title: Strings.Unlocalized.uuid,
|
title: Strings.Unlocalized.uuid,
|
||||||
value: uuid.uuidString
|
value: profileEditor.id.uuidString
|
||||||
)
|
)
|
||||||
} header: {
|
|
||||||
Text(Strings.Global.storage)
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
EmptyView()
|
|
||||||
#endif
|
#endif
|
||||||
|
}
|
||||||
|
.themeSection(
|
||||||
|
header: Strings.Global.storage,
|
||||||
|
footer: Strings.Modules.General.Sections.Storage.footer
|
||||||
|
)
|
||||||
|
.modifier(PaywallModifier(reason: $paywallReason))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension StorageSection {
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var sharingToggle: some View {
|
||||||
|
switch iapManager.paywallReason(forFeature: .sharing) {
|
||||||
|
case .purchase(let appFeature):
|
||||||
|
Button(Strings.Modules.General.Storage.Shared.purchase) {
|
||||||
|
paywallReason = .purchase(appFeature)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .restricted:
|
||||||
|
EmptyView()
|
||||||
|
|
||||||
|
default:
|
||||||
|
Toggle(Strings.Modules.General.Storage.shared, isOn: $profileEditor.isShared)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
Form {
|
Form {
|
||||||
StorageSection(
|
StorageSection(
|
||||||
uuid: ProfileEditor().id
|
profileEditor: ProfileEditor()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.themeForm()
|
.themeForm()
|
||||||
|
|
|
@ -31,6 +31,8 @@ extension BundleConfiguration {
|
||||||
public enum BundleKey: String {
|
public enum BundleKey: String {
|
||||||
case appStoreId
|
case appStoreId
|
||||||
|
|
||||||
|
case cloudKitId
|
||||||
|
|
||||||
case customUserLevel
|
case customUserLevel
|
||||||
|
|
||||||
case groupId
|
case groupId
|
||||||
|
@ -41,7 +43,15 @@ extension BundleConfiguration {
|
||||||
|
|
||||||
case profilesContainerName
|
case profilesContainerName
|
||||||
|
|
||||||
|
case remoteProfilesContainerName
|
||||||
|
|
||||||
case tunnelId
|
case tunnelId
|
||||||
|
|
||||||
|
// legacy v2
|
||||||
|
|
||||||
|
case legacyV2CloudKitId
|
||||||
|
|
||||||
|
case legacyV2ProfilesContainerName
|
||||||
}
|
}
|
||||||
|
|
||||||
public static var mainDisplayName: String {
|
public static var mainDisplayName: String {
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@objc(CDProfile)
|
||||||
|
final class CDProfile: NSManagedObject {
|
||||||
|
@nonobjc static func fetchRequest() -> NSFetchRequest<CDProfile> {
|
||||||
|
return NSFetchRequest<CDProfile>(entityName: "CDProfile")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged var json: Data?
|
||||||
|
@NSManaged var encryptedJSON: Data?
|
||||||
|
@NSManaged var name: String?
|
||||||
|
@NSManaged var providerName: String?
|
||||||
|
@NSManaged var uuid: UUID?
|
||||||
|
@NSManaged var lastUpdate: Date?
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
//
|
||||||
|
// CDProfileRepositoryV2.swift
|
||||||
|
// Passepartout
|
||||||
|
//
|
||||||
|
// Created by Davide De Rosa on 10/1/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
|
||||||
|
import PassepartoutKit
|
||||||
|
|
||||||
|
final class CDProfileRepositoryV2 {
|
||||||
|
static var model: NSManagedObjectModel {
|
||||||
|
guard let model: NSManagedObjectModel = .mergedModel(from: [.module]) else {
|
||||||
|
fatalError("Unable to build Core Data model (Profiles v2)")
|
||||||
|
}
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
private let context: NSManagedObjectContext
|
||||||
|
|
||||||
|
init(context: NSManagedObjectContext) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: #586, migrate profiles properly
|
||||||
|
func migratedProfiles() async throws -> [Profile] {
|
||||||
|
try await context.perform { [weak self] in
|
||||||
|
guard let self else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let request = CDProfile.fetchRequest()
|
||||||
|
let existing = try context.fetch(request)
|
||||||
|
// existing.forEach {
|
||||||
|
// guard let json = $0.encryptedJSON,
|
||||||
|
// let string = String(data: json, encoding: .utf8) else {
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// print(">>> \(string)")
|
||||||
|
// }
|
||||||
|
return existing.compactMap {
|
||||||
|
guard let name = $0.name else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return try? Profile.Builder(name: name).tryBuild()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
//
|
||||||
|
// LegacyV2.swift
|
||||||
|
// Passepartout
|
||||||
|
//
|
||||||
|
// Created by Davide De Rosa on 10/1/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
|
||||||
|
import UtilsLibrary
|
||||||
|
|
||||||
|
public final class LegacyV2 {
|
||||||
|
private let profilesRepository: CDProfileRepositoryV2
|
||||||
|
|
||||||
|
private let cloudKitIdentifier: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
profilesContainerName: String,
|
||||||
|
cloudKitIdentifier: String,
|
||||||
|
coreDataLogger: CoreDataPersistentStoreLogger
|
||||||
|
) {
|
||||||
|
let store = CoreDataPersistentStore(
|
||||||
|
logger: coreDataLogger,
|
||||||
|
containerName: profilesContainerName,
|
||||||
|
model: CDProfileRepositoryV2.model,
|
||||||
|
cloudKitIdentifier: cloudKitIdentifier,
|
||||||
|
author: nil
|
||||||
|
)
|
||||||
|
profilesRepository = CDProfileRepositoryV2(context: store.context)
|
||||||
|
self.cloudKitIdentifier = cloudKitIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
public func fetchProfiles() async throws -> [Profile] {
|
||||||
|
try await profilesRepository.migratedProfiles()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>_XCCurrentVersionName</key>
|
||||||
|
<string>Profiles.xcdatamodel</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23G93" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="1.0">
|
||||||
|
<entity name="CDProfile" representedClassName="CDProfile" syncable="YES">
|
||||||
|
<attribute name="encryptedJSON" optional="YES" attributeType="Binary" allowsCloudEncryption="YES"/>
|
||||||
|
<attribute name="json" optional="YES" attributeType="Binary"/>
|
||||||
|
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="name" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="providerName" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="uuid" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
</entity>
|
||||||
|
</model>
|
|
@ -43,14 +43,13 @@ public final class CoreDataPersistentStore {
|
||||||
logger: CoreDataPersistentStoreLogger,
|
logger: CoreDataPersistentStoreLogger,
|
||||||
containerName: String,
|
containerName: String,
|
||||||
model: NSManagedObjectModel,
|
model: NSManagedObjectModel,
|
||||||
cloudKit: Bool,
|
|
||||||
cloudKitIdentifier: String?,
|
cloudKitIdentifier: String?,
|
||||||
author: String?
|
author: String?
|
||||||
) {
|
) {
|
||||||
let container: NSPersistentContainer
|
let container: NSPersistentContainer
|
||||||
if cloudKit {
|
if let cloudKitIdentifier {
|
||||||
container = NSPersistentCloudKitContainer(name: containerName, managedObjectModel: model)
|
container = NSPersistentCloudKitContainer(name: containerName, managedObjectModel: model)
|
||||||
logger.debug("Set up CloudKit container: \(containerName)")
|
logger.debug("Set up CloudKit container (\(cloudKitIdentifier)): \(containerName)")
|
||||||
} else {
|
} else {
|
||||||
container = NSPersistentContainer(name: containerName, managedObjectModel: model)
|
container = NSPersistentContainer(name: containerName, managedObjectModel: model)
|
||||||
logger.debug("Set up local container: \(containerName)")
|
logger.debug("Set up local container: \(containerName)")
|
||||||
|
@ -98,7 +97,7 @@ public final class CoreDataPersistentStore {
|
||||||
container.viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
container.viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
||||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||||
|
|
||||||
if let author = author {
|
if let author {
|
||||||
logger.debug("Setting transaction author: \(author)")
|
logger.debug("Setting transaction author: \(author)")
|
||||||
container.viewContext.transactionAuthor = author
|
container.viewContext.transactionAuthor = author
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,21 +39,35 @@ extension ProfileManager {
|
||||||
logger: .default,
|
logger: .default,
|
||||||
containerName: BundleConfiguration.mainString(for: .profilesContainerName),
|
containerName: BundleConfiguration.mainString(for: .profilesContainerName),
|
||||||
model: model,
|
model: model,
|
||||||
cloudKit: false,
|
|
||||||
cloudKitIdentifier: nil,
|
cloudKitIdentifier: nil,
|
||||||
author: nil
|
author: nil
|
||||||
)
|
)
|
||||||
|
let repository = AppData.cdProfileRepositoryV3(
|
||||||
let repository = AppData.cdProfileRepository(
|
|
||||||
registry: .shared,
|
registry: .shared,
|
||||||
coder: CodableProfileCoder(),
|
coder: CodableProfileCoder(),
|
||||||
context: store.context
|
context: store.context
|
||||||
) { error in
|
) { error in
|
||||||
pp_log(.app, .error, "Unable to decode result: \(error)")
|
pp_log(.app, .error, "Unable to decode local result: \(error)")
|
||||||
return .ignore
|
return .ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProfileManager(repository: repository)
|
let remoteStore = CoreDataPersistentStore(
|
||||||
|
logger: .default,
|
||||||
|
containerName: BundleConfiguration.mainString(for: .remoteProfilesContainerName),
|
||||||
|
model: model,
|
||||||
|
cloudKitIdentifier: BundleConfiguration.mainString(for: .cloudKitId),
|
||||||
|
author: nil
|
||||||
|
)
|
||||||
|
let remoteRepository = AppData.cdProfileRepositoryV3(
|
||||||
|
registry: .shared,
|
||||||
|
coder: CodableProfileCoder(),
|
||||||
|
context: remoteStore.context
|
||||||
|
) { error in
|
||||||
|
pp_log(.app, .error, "Unable to decode remote result: \(error)")
|
||||||
|
return .ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProfileManager(repository: repository, remoteRepository: remoteRepository)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue