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">
|
||||
<plist version="1.0">
|
||||
<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>
|
||||
<array>
|
||||
<string>packet-tunnel-provider</string>
|
||||
</array>
|
||||
<key>com.apple.developer.networking.wifi-info</key>
|
||||
<true/>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(CFG_GROUP_ID)</string>
|
||||
</array>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)$(CFG_GROUP_ID)</string>
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
<dict>
|
||||
<key>appStoreId</key>
|
||||
<string>$(CFG_APP_STORE_ID)</string>
|
||||
<key>cloudKitId</key>
|
||||
<string>$(CFG_CLOUDKIT_ID)</string>
|
||||
<key>groupId</key>
|
||||
<string>$(CFG_GROUP_ID)</string>
|
||||
<key>iapBundlePrefix</key>
|
||||
|
@ -14,8 +16,14 @@
|
|||
<string>$(CFG_TEAM_ID).$(CFG_GROUP_ID)</string>
|
||||
<key>profilesContainerName</key>
|
||||
<string>$(CFG_PROFILES_CONTAINER_NAME)</string>
|
||||
<key>remoteProfilesContainerName</key>
|
||||
<string>$(CFG_PROFILES_CONTAINER_NAME).remote</string>
|
||||
<key>tunnelId</key>
|
||||
<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>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
|
@ -52,5 +60,9 @@
|
|||
<array>
|
||||
<string>CustomIntentIntent</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
|
||||
CFG_APP_ID = com.algoritmico.ios.Passepartout
|
||||
CFG_APP_STORE_ID = 1433648537
|
||||
CFG_CLOUDKIT_ID = iCloud.com.algoritmico.Passepartout.v3
|
||||
CFG_COPYRIGHT = Copyright © 2024 Davide De Rosa. All rights reserved.
|
||||
CFG_DISPLAY_NAME = Passepartout
|
||||
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_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
|
||||
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"
|
||||
]
|
||||
),
|
||||
.library(
|
||||
name: "IntentsLibrary",
|
||||
targets: [
|
||||
"AppDataProfiles",
|
||||
"AppLibrary"
|
||||
]
|
||||
),
|
||||
.library(
|
||||
name: "TunnelLibrary",
|
||||
targets: ["CommonLibrary"]
|
||||
|
@ -72,6 +65,7 @@ let package = Package(
|
|||
"AppData",
|
||||
"CommonLibrary",
|
||||
"Kvitto",
|
||||
"LegacyV2",
|
||||
"UtilsLibrary"
|
||||
]
|
||||
),
|
||||
|
@ -93,6 +87,15 @@ let package = Package(
|
|||
.process("Resources")
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "LegacyV2",
|
||||
dependencies: [
|
||||
.product(name: "PassepartoutKit", package: "passepartoutkit")
|
||||
],
|
||||
resources: [
|
||||
.process("Profiles.xcdatamodeld")
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "UtilsLibrary"
|
||||
),
|
||||
|
|
|
@ -28,12 +28,10 @@ import CoreData
|
|||
import Foundation
|
||||
|
||||
extension AppData {
|
||||
|
||||
@MainActor
|
||||
public static let cdProfilesModel: NSManagedObjectModel = {
|
||||
public static var cdProfilesModel: NSManagedObjectModel {
|
||||
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
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// CDProfileRepository.swift
|
||||
// CDProfileRepositoryV3.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 8/11/24.
|
||||
|
@ -30,14 +30,15 @@ import PassepartoutKit
|
|||
import UtilsLibrary
|
||||
|
||||
extension AppData {
|
||||
|
||||
// TODO: #656, make non-static
|
||||
public static func cdProfileRepository(
|
||||
public static func cdProfileRepositoryV3(
|
||||
registry: Registry,
|
||||
coder: ProfileCoder,
|
||||
context: NSManagedObjectContext,
|
||||
onResultError: ((Error) -> CoreDataResultAction)?
|
||||
) -> any ProfileRepository {
|
||||
let repository = CoreDataRepository<CDProfile, Profile>(context: context) {
|
||||
let repository = CoreDataRepository<CDProfileV3, Profile>(context: context) {
|
||||
$0.sortDescriptors = [
|
||||
.init(key: "name", ascending: true, selector: #selector(NSString.caseInsensitiveCompare)),
|
||||
.init(key: "lastUpdate", ascending: true)
|
||||
|
@ -50,7 +51,7 @@ extension AppData {
|
|||
} toMapper: {
|
||||
let encoded = try registry.encodedProfile($0, with: coder)
|
||||
|
||||
let cdProfile = CDProfile(context: $1)
|
||||
let cdProfile = CDProfileV3(context: $1)
|
||||
cdProfile.uuid = $0.id
|
||||
cdProfile.name = $0.name
|
||||
cdProfile.encoded = encoded
|
||||
|
@ -64,7 +65,7 @@ extension AppData {
|
|||
}
|
||||
}
|
||||
|
||||
extension CDProfile: CoreDataUniqueEntity {
|
||||
extension CDProfileV3: CoreDataUniqueEntity {
|
||||
}
|
||||
|
||||
extension CoreDataRepository: ProfileRepository where T == Profile {
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// CDProfile.swift
|
||||
// CDProfileV3.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 8/11/24.
|
||||
|
@ -26,8 +26,8 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
|
||||
@objc(CDProfile)
|
||||
final class CDProfile: NSManagedObject {
|
||||
@objc(CDProfileV3)
|
||||
final class CDProfileV3: NSManagedObject {
|
||||
@NSManaged var uuid: UUID?
|
||||
@NSManaged var name: 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"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
|
||||
<entity name="CDProfile" representedClassName="CDProfile" syncable="YES">
|
||||
<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" 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"/>
|
||||
|
|
|
@ -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 remove([Profile.ID])
|
||||
|
||||
case update([Profile])
|
||||
}
|
||||
|
||||
public var beforeSave: ((Profile) async throws -> 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
|
||||
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>
|
||||
|
||||
|
@ -58,21 +64,27 @@ public final class ProfileManager: ObservableObject {
|
|||
|
||||
// for testing/previews
|
||||
public init(profiles: [Profile]) {
|
||||
didChange = PassthroughSubject()
|
||||
self.profiles = profiles.sorted {
|
||||
$0.name.lowercased() < $1.name.lowercased()
|
||||
}
|
||||
allProfileIds = []
|
||||
repository = MockProfileRepository(profiles: profiles)
|
||||
remoteRepository = nil
|
||||
self.profiles = []
|
||||
allProfiles = profiles.reduce(into: [:]) {
|
||||
$0[$1.id] = $1
|
||||
}
|
||||
allRemoteProfiles = [:]
|
||||
|
||||
didChange = PassthroughSubject()
|
||||
searchSubject = CurrentValueSubject("")
|
||||
subscriptions = []
|
||||
}
|
||||
|
||||
public init(repository: any ProfileRepository) {
|
||||
didChange = PassthroughSubject()
|
||||
profiles = []
|
||||
allProfileIds = []
|
||||
public init(repository: any ProfileRepository, remoteRepository: (any ProfileRepository)?) {
|
||||
self.repository = repository
|
||||
self.remoteRepository = remoteRepository
|
||||
profiles = []
|
||||
allProfiles = [:]
|
||||
allRemoteProfiles = [:]
|
||||
|
||||
didChange = PassthroughSubject()
|
||||
searchSubject = CurrentValueSubject("")
|
||||
subscriptions = []
|
||||
}
|
||||
|
@ -109,6 +121,7 @@ extension ProfileManager {
|
|||
do {
|
||||
try await beforeSave?(profile)
|
||||
try await repository.saveEntities([profile])
|
||||
allProfiles[profile.id] = profile
|
||||
didChange.send(.save(profile))
|
||||
} catch {
|
||||
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 {
|
||||
do {
|
||||
allProfileIds.subtract(profileIds)
|
||||
var newAllProfiles = allProfiles
|
||||
try await repository.removeEntities(withIds: profileIds)
|
||||
profileIds.forEach {
|
||||
newAllProfiles.removeValue(forKey: $0)
|
||||
}
|
||||
await afterRemove?(profileIds)
|
||||
allProfiles = newAllProfiles
|
||||
didChange.send(.remove(profileIds))
|
||||
} catch {
|
||||
pp_log(.app, .fault, "Unable to remove profiles \(profileIds): \(error)")
|
||||
|
@ -132,7 +149,30 @@ extension ProfileManager {
|
|||
}
|
||||
|
||||
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) {
|
||||
repository
|
||||
.entitiesPublisher
|
||||
.first()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.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)
|
||||
|
||||
|
@ -199,29 +248,45 @@ extension ProfileManager {
|
|||
}
|
||||
|
||||
private extension ProfileManager {
|
||||
func notifyUpdatedEntities(_ result: EntitiesResult<Profile>) {
|
||||
let oldProfiles = profiles.reduce(into: [:]) {
|
||||
func notifyLocalEntities(_ result: EntitiesResult<Profile>) {
|
||||
allProfiles = result.entities.reduce(into: [:]) {
|
||||
$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 {
|
||||
allProfileIds = Set(newProfiles.map(\.id))
|
||||
// pull remote updates into local profiles (best-effort)
|
||||
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) {
|
||||
Task {
|
||||
guard !search.isEmpty else {
|
||||
try await repository.resetFilter()
|
||||
return
|
||||
reloadFilteredProfiles(with: search)
|
||||
}
|
||||
|
||||
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
|
||||
var name: String
|
||||
|
||||
@Published
|
||||
var isShared: Bool
|
||||
|
||||
@Published
|
||||
private(set) var modules: [any EditableModule]
|
||||
|
||||
|
@ -58,6 +61,7 @@ final class ProfileEditor: ObservableObject {
|
|||
activeModulesIds = Set(modules.map(\.id))
|
||||
moduleNames = [:]
|
||||
removedModules = [:]
|
||||
isShared = false
|
||||
}
|
||||
|
||||
init(profile: Profile) {
|
||||
|
@ -67,15 +71,17 @@ final class ProfileEditor: ObservableObject {
|
|||
activeModulesIds = profile.activeModulesIds
|
||||
moduleNames = profile.moduleNames
|
||||
removedModules = [:]
|
||||
isShared = false
|
||||
}
|
||||
|
||||
func editProfile(_ profile: Profile) {
|
||||
func editProfile(_ profile: Profile, isShared: Bool) {
|
||||
id = profile.id
|
||||
name = profile.name
|
||||
modules = profile.modulesBuilders
|
||||
activeModulesIds = profile.activeModulesIds
|
||||
moduleNames = profile.moduleNames
|
||||
removedModules = [:]
|
||||
self.isShared = isShared
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -267,6 +273,7 @@ extension ProfileEditor {
|
|||
do {
|
||||
let newProfile = try build()
|
||||
try await profileManager.save(newProfile)
|
||||
try await profileManager.setRemotelyShared(isShared, profileWithId: newProfile.id)
|
||||
} catch {
|
||||
pp_log(.app, .fault, "Unable to save edited profile: \(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 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 BypassDomains {
|
||||
/// Add bypass domain
|
||||
|
|
|
@ -41,6 +41,7 @@ extension AppContext {
|
|||
iapManager: IAPManager(
|
||||
customUserLevel: nil,
|
||||
receiptReader: MockReceiptReader(),
|
||||
unrestrictedFeatures: [.sharing],
|
||||
productsAtBuild: { _ in
|
||||
[]
|
||||
}
|
||||
|
|
|
@ -159,6 +159,10 @@
|
|||
|
||||
// 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.search_domains.add" = "Add domain";
|
||||
"modules.http_proxy.bypass_domains.add" = "Add bypass domain";
|
||||
|
|
|
@ -140,7 +140,7 @@ private extension AppInlineCoordinator {
|
|||
}
|
||||
|
||||
func enterDetail(of profile: Profile) {
|
||||
profileEditor.editProfile(profile)
|
||||
profileEditor.editProfile(profile, isShared: profileManager.isRemotelyShared(profileWithId: profile.id))
|
||||
push(.editProfile)
|
||||
}
|
||||
|
||||
|
|
|
@ -132,7 +132,7 @@ extension AppModalCoordinator {
|
|||
|
||||
func enterDetail(of profile: Profile) {
|
||||
profilePath = NavigationPath()
|
||||
profileEditor.editProfile(profile)
|
||||
profileEditor.editProfile(profile, isShared: profileManager.isRemotelyShared(profileWithId: profile.id))
|
||||
modalRoute = .editProfile
|
||||
}
|
||||
}
|
||||
|
|
|
@ -64,7 +64,7 @@ struct ProfileEditView: View, Routable {
|
|||
Text(Strings.Views.Profile.ModuleList.Section.footer)
|
||||
}
|
||||
StorageSection(
|
||||
uuid: profileEditor.id
|
||||
profileEditor: profileEditor
|
||||
)
|
||||
}
|
||||
.toolbar(content: toolbarContent)
|
||||
|
|
|
@ -39,7 +39,7 @@ struct ProfileGeneralView: View {
|
|||
placeholder: Strings.Placeholders.Profile.name
|
||||
)
|
||||
StorageSection(
|
||||
uuid: profileEditor.id
|
||||
profileEditor: profileEditor
|
||||
)
|
||||
}
|
||||
.themeForm()
|
||||
|
|
|
@ -29,6 +29,7 @@ extension Theme {
|
|||
public enum ImageName {
|
||||
case add
|
||||
case close
|
||||
case cloud
|
||||
case contextDuplicate
|
||||
case contextRemove
|
||||
case copy
|
||||
|
|
|
@ -80,6 +80,7 @@ public final class Theme: ObservableObject {
|
|||
switch $0 {
|
||||
case .add: return "plus"
|
||||
case .close: return "xmark"
|
||||
case .cloud: return "cloud"
|
||||
case .contextDuplicate: return "plus.square.on.square"
|
||||
case .contextRemove: return "trash"
|
||||
case .copy: return "doc.on.doc"
|
||||
|
|
|
@ -37,18 +37,30 @@ struct ProfileCardView: View {
|
|||
|
||||
let header: ProfileHeader
|
||||
|
||||
let isShared: Bool
|
||||
|
||||
var body: some View {
|
||||
switch style {
|
||||
case .compact:
|
||||
Text(header.name)
|
||||
.themeTruncating()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
HStack {
|
||||
Text(header.name)
|
||||
.themeTruncating()
|
||||
if isShared {
|
||||
ThemeImage(.cloud)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
case .full:
|
||||
VStack(alignment: .leading) {
|
||||
Text(header.name)
|
||||
.font(.headline)
|
||||
.themeTruncating()
|
||||
HStack {
|
||||
Text(header.name)
|
||||
.font(.headline)
|
||||
.themeTruncating()
|
||||
if isShared {
|
||||
ThemeImage(.cloud)
|
||||
}
|
||||
}
|
||||
Text(Strings.Views.Profiles.Rows.modules(header.modules.count))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
@ -63,10 +75,19 @@ struct ProfileCardView: View {
|
|||
#Preview {
|
||||
List {
|
||||
Section {
|
||||
ProfileCardView(style: .compact, header: Profile.mock.header())
|
||||
ProfileCardView(
|
||||
style: .compact,
|
||||
header: Profile.mock.header(),
|
||||
isShared: true
|
||||
)
|
||||
}
|
||||
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,
|
||||
errorHandler: errorHandler
|
||||
) { _ in
|
||||
ProfileCardView(style: style, header: header)
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(.rect)
|
||||
ProfileCardView(
|
||||
style: style,
|
||||
header: header,
|
||||
isShared: profileManager.isRemotelyShared(profileWithId: header.id)
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -27,29 +27,58 @@ import Foundation
|
|||
import SwiftUI
|
||||
|
||||
struct StorageSection: View {
|
||||
let uuid: UUID
|
||||
|
||||
@EnvironmentObject
|
||||
private var iapManager: IAPManager
|
||||
|
||||
@ObservedObject
|
||||
var profileEditor: ProfileEditor
|
||||
|
||||
@State
|
||||
private var paywallReason: PaywallReason?
|
||||
|
||||
var body: some View {
|
||||
#if DEBUG
|
||||
debugChanges()
|
||||
return Section {
|
||||
return Group {
|
||||
sharingToggle
|
||||
#if DEBUG
|
||||
ThemeCopiableText(
|
||||
title: Strings.Unlocalized.uuid,
|
||||
value: uuid.uuidString
|
||||
value: profileEditor.id.uuidString
|
||||
)
|
||||
} header: {
|
||||
Text(Strings.Global.storage)
|
||||
}
|
||||
#else
|
||||
EmptyView()
|
||||
#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 {
|
||||
Form {
|
||||
StorageSection(
|
||||
uuid: ProfileEditor().id
|
||||
profileEditor: ProfileEditor()
|
||||
)
|
||||
}
|
||||
.themeForm()
|
||||
|
|
|
@ -31,6 +31,8 @@ extension BundleConfiguration {
|
|||
public enum BundleKey: String {
|
||||
case appStoreId
|
||||
|
||||
case cloudKitId
|
||||
|
||||
case customUserLevel
|
||||
|
||||
case groupId
|
||||
|
@ -41,7 +43,15 @@ extension BundleConfiguration {
|
|||
|
||||
case profilesContainerName
|
||||
|
||||
case remoteProfilesContainerName
|
||||
|
||||
case tunnelId
|
||||
|
||||
// legacy v2
|
||||
|
||||
case legacyV2CloudKitId
|
||||
|
||||
case legacyV2ProfilesContainerName
|
||||
}
|
||||
|
||||
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,
|
||||
containerName: String,
|
||||
model: NSManagedObjectModel,
|
||||
cloudKit: Bool,
|
||||
cloudKitIdentifier: String?,
|
||||
author: String?
|
||||
) {
|
||||
let container: NSPersistentContainer
|
||||
if cloudKit {
|
||||
if let cloudKitIdentifier {
|
||||
container = NSPersistentCloudKitContainer(name: containerName, managedObjectModel: model)
|
||||
logger.debug("Set up CloudKit container: \(containerName)")
|
||||
logger.debug("Set up CloudKit container (\(cloudKitIdentifier)): \(containerName)")
|
||||
} else {
|
||||
container = NSPersistentContainer(name: containerName, managedObjectModel: model)
|
||||
logger.debug("Set up local container: \(containerName)")
|
||||
|
@ -98,7 +97,7 @@ public final class CoreDataPersistentStore {
|
|||
container.viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
|
||||
if let author = author {
|
||||
if let author {
|
||||
logger.debug("Setting transaction author: \(author)")
|
||||
container.viewContext.transactionAuthor = author
|
||||
}
|
||||
|
|
|
@ -39,21 +39,35 @@ extension ProfileManager {
|
|||
logger: .default,
|
||||
containerName: BundleConfiguration.mainString(for: .profilesContainerName),
|
||||
model: model,
|
||||
cloudKit: false,
|
||||
cloudKitIdentifier: nil,
|
||||
author: nil
|
||||
)
|
||||
|
||||
let repository = AppData.cdProfileRepository(
|
||||
let repository = AppData.cdProfileRepositoryV3(
|
||||
registry: .shared,
|
||||
coder: CodableProfileCoder(),
|
||||
context: store.context
|
||||
) { error in
|
||||
pp_log(.app, .error, "Unable to decode result: \(error)")
|
||||
pp_log(.app, .error, "Unable to decode local result: \(error)")
|
||||
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