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:
Davide 2024-10-03 18:41:27 +02:00 committed by GitHub
parent 1227df60ff
commit 1491766102
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 473 additions and 160 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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)

View File

@ -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>

View File

@ -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"
), ),

View File

@ -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
}() }
} }

View File

@ -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 {

View File

@ -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?

View File

@ -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>

View File

@ -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"/>

View File

@ -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>

View File

@ -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)
}
} }
} }

View File

@ -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

View File

@ -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

View File

@ -41,6 +41,7 @@ extension AppContext {
iapManager: IAPManager( iapManager: IAPManager(
customUserLevel: nil, customUserLevel: nil,
receiptReader: MockReceiptReader(), receiptReader: MockReceiptReader(),
unrestrictedFeatures: [.sharing],
productsAtBuild: { _ in productsAtBuild: { _ in
[] []
} }

View File

@ -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";

View File

@ -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)
} }

View File

@ -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
} }
} }

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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"

View File

@ -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()
} }

View File

@ -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)
} }
} }

View File

@ -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()

View File

@ -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 {

View File

@ -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?
}

View File

@ -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
}
}
}
}

View File

@ -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()
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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
} }

View File

@ -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)
}() }()
} }