diff --git a/Passepartout/App/Constants/Constants+App.swift b/Passepartout/App/Constants/Constants+App.swift index dfdef48a..b65d87b7 100644 --- a/Passepartout/App/Constants/Constants+App.swift +++ b/Passepartout/App/Constants/Constants+App.swift @@ -45,6 +45,12 @@ extension Constants { }() } + enum CloudKit { + static let containerId: String = bundleConfig("cloudkit_id") + + static let coreDataZone = "com.apple.coredata.cloudkit.zone" + } + enum Plugins { static let macBridgeName = "PassepartoutMac.bundle" } diff --git a/Passepartout/App/Context/AppContext.swift b/Passepartout/App/Context/AppContext.swift index 06c1419e..a917c702 100644 --- a/Passepartout/App/Context/AppContext.swift +++ b/Passepartout/App/Context/AppContext.swift @@ -137,4 +137,8 @@ extension AppContext { lastIsCloudSyncingEnabled = isCloudSyncingEnabled } } + + func eraseCloudKitStore() async { + await coreContext.eraseCloudKitStore() + } } diff --git a/Passepartout/App/Context/CoreContext.swift b/Passepartout/App/Context/CoreContext.swift index 21760d4f..a243795f 100644 --- a/Passepartout/App/Context/CoreContext.swift +++ b/Passepartout/App/Context/CoreContext.swift @@ -23,6 +23,7 @@ // along with Passepartout. If not, see . // +import CloudKit import Combine import Foundation import PassepartoutLibrary @@ -35,6 +36,8 @@ final class CoreContext { private let persistenceManager: PersistenceManager + private(set) var vpnPersistence: VPNPersistence + let upgradeManager: UpgradeManager let providerManager: ProviderManager @@ -63,7 +66,7 @@ final class CoreContext { upgradeManager.migrate(toVersion: Constants.Global.appVersionNumber) persistenceManager = PersistenceManager(store: store) - let vpnPersistence = persistenceManager.vpnPersistence( + vpnPersistence = persistenceManager.vpnPersistence( withName: Constants.Persistence.profilesContainerName, cloudKit: store.isCloudSyncingEnabled ) @@ -140,14 +143,17 @@ private extension CoreContext { extension CoreContext { func reloadCloudKitObjects(isEnabled: Bool) { - let vpnPersistence = persistenceManager.vpnPersistence( + vpnPersistence = persistenceManager.vpnPersistence( withName: Constants.Persistence.profilesContainerName, cloudKit: isEnabled ) profileManager.swapProfileRepository(vpnPersistence.profileRepository()) } - func eraseCloudKitStore() { - // TODO: iCloud, erase remote records + func eraseCloudKitStore() async { + await vpnPersistence.eraseCloudKitStore( + fromContainerWithId: Constants.CloudKit.containerId, + zoneId: .init(zoneName: Constants.CloudKit.coreDataZone) + ) } } diff --git a/Passepartout/App/Info.plist b/Passepartout/App/Info.plist index a5f31457..d6b6dbf8 100644 --- a/Passepartout/App/Info.plist +++ b/Passepartout/App/Info.plist @@ -101,6 +101,8 @@ $(CFG_APPSTORE_ID) group_id group.$(CFG_GROUP_ID) + cloudkit_id + iCloud.$(CFG_GROUP_ID) diff --git a/Passepartout/App/L10n/Unlocalized.swift b/Passepartout/App/L10n/Unlocalized.swift index ddc3a9ec..9f9fe7ff 100644 --- a/Passepartout/App/L10n/Unlocalized.swift +++ b/Passepartout/App/L10n/Unlocalized.swift @@ -257,6 +257,8 @@ enum Unlocalized { enum Other { static let siri = "Siri" + static let iCloud = "iCloud" + static let totp = "TOTP" } } diff --git a/Passepartout/App/Views/SettingsView.swift b/Passepartout/App/Views/SettingsView.swift index 78bdbf7d..4c832627 100644 --- a/Passepartout/App/Views/SettingsView.swift +++ b/Passepartout/App/Views/SettingsView.swift @@ -37,6 +37,8 @@ struct SettingsView: View { @Binding private var shouldEnableCloudSyncing: Bool + @State private var isErasingCloudStore = false + private let versionString = Constants.Global.appVersionString init() { @@ -45,14 +47,20 @@ struct SettingsView: View { _shouldEnableCloudSyncing = .init { AppContext.shared.shouldEnableCloudSyncing - } set: { - AppContext.shared.shouldEnableCloudSyncing = $0 + } set: { isEnabled in + withAnimation { + AppContext.shared.shouldEnableCloudSyncing = isEnabled + } } } var body: some View { List { + #if !targetEnvironment(macCatalyst) preferencesSection + #endif + iCloudSection + diagnosticsSection aboutSection }.toolbar { themeCloseItem(presentationMode: presentationMode) @@ -66,29 +74,48 @@ struct SettingsView: View { private extension SettingsView { var preferencesSection: some View { Section { - #if !targetEnvironment(macCatalyst) Toggle(L10n.Settings.Items.LocksInBackground.caption, isOn: $locksInBackground) - #endif - Toggle(L10n.Settings.Items.ShouldEnableCloudSyncing.caption, isOn: $shouldEnableCloudSyncing) } header: { Text(L10n.Preferences.title) } } + var iCloudSection: some View { + Section { + Toggle(L10n.Settings.Items.ShouldEnableCloudSyncing.caption, isOn: $shouldEnableCloudSyncing) + Button(L10n.Settings.Items.EraseCloudStore.caption) { + isErasingCloudStore = true + Task { + await AppContext.shared.eraseCloudKitStore() + isErasingCloudStore = false + } + }.withTrailingProgress(when: isErasingCloudStore) + .disabled(shouldEnableCloudSyncing || isErasingCloudStore) + } header: { + Text(Unlocalized.Other.iCloud) + } footer: { + Text(L10n.Settings.Sections.Icloud.footer) + } + } + + var diagnosticsSection: some View { + Section { + DiagnosticsRow(currentProfile: profileManager.currentProfile) + } + } + var aboutSection: some View { Section { - NavigationLink { - AboutView() - } label: { - Text(L10n.About.title) - } NavigationLink { DonateView() } label: { Text(L10n.Settings.Items.Donate.caption) }.disabled(!productManager.canMakePayments()) - - DiagnosticsRow(currentProfile: profileManager.currentProfile) + NavigationLink { + AboutView() + } label: { + Text(L10n.About.title) + } } footer: { HStack { Spacer() diff --git a/Passepartout/App/en.lproj/Localizable.strings b/Passepartout/App/en.lproj/Localizable.strings index 4a890f92..3f847701 100644 --- a/Passepartout/App/en.lproj/Localizable.strings +++ b/Passepartout/App/en.lproj/Localizable.strings @@ -331,8 +331,10 @@ /* MARK: SettingsView */ "settings.title" = "Settings"; +"settings.sections.icloud.footer" = "Disable sync to allow erase. To erase the iCloud store securely, do so on all your synced devices. This will not affect local profiles."; "settings.items.locks_in_background.caption" = "Lock app access"; "settings.items.should_enable_cloud_syncing.caption" = "Sync with iCloud"; +"settings.items.erase_cloud_store.caption" = "Erase iCloud store"; "settings.items.donate.caption" = "Make a donation"; /* MARK: AboutView */ diff --git a/Passepartout/AppShared/Constants/SwiftGen+Strings.swift b/Passepartout/AppShared/Constants/SwiftGen+Strings.swift index 31b9a02c..da819ff6 100644 --- a/Passepartout/AppShared/Constants/SwiftGen+Strings.swift +++ b/Passepartout/AppShared/Constants/SwiftGen+Strings.swift @@ -921,6 +921,10 @@ internal enum L10n { /// Make a donation internal static let caption = L10n.tr("Localizable", "settings.items.donate.caption", fallback: "Make a donation") } + internal enum EraseCloudStore { + /// Erase iCloud store + internal static let caption = L10n.tr("Localizable", "settings.items.erase_cloud_store.caption", fallback: "Erase iCloud store") + } internal enum LocksInBackground { /// Lock app access internal static let caption = L10n.tr("Localizable", "settings.items.locks_in_background.caption", fallback: "Lock app access") @@ -930,6 +934,12 @@ internal enum L10n { internal static let caption = L10n.tr("Localizable", "settings.items.should_enable_cloud_syncing.caption", fallback: "Sync with iCloud") } } + internal enum Sections { + internal enum Icloud { + /// Disable sync to allow erase. To erase the iCloud store securely, do so on all your synced devices. This will not affect local profiles. + internal static let footer = L10n.tr("Localizable", "settings.sections.icloud.footer", fallback: "Disable sync to allow erase. To erase the iCloud store securely, do so on all your synced devices. This will not affect local profiles.") + } + } } internal enum Shortcuts { internal enum Add { diff --git a/PassepartoutLibrary/Sources/PassepartoutCore/Reusable/CoreDataPersistentStore.swift b/PassepartoutLibrary/Sources/PassepartoutCore/Reusable/CoreDataPersistentStore.swift index 1e4eddaa..4b4d85ef 100644 --- a/PassepartoutLibrary/Sources/PassepartoutCore/Reusable/CoreDataPersistentStore.swift +++ b/PassepartoutLibrary/Sources/PassepartoutCore/Reusable/CoreDataPersistentStore.swift @@ -23,6 +23,7 @@ // along with Passepartout. If not, see . // +import CloudKit import Combine import CoreData import Foundation @@ -69,7 +70,25 @@ public final class CoreDataPersistentStore { container.viewContext.transactionAuthor = author } } +} +extension CoreDataPersistentStore { + public var context: NSManagedObjectContext { + container.viewContext + } + + public var backgroundContext: NSManagedObjectContext { + container.newBackgroundContext() + } + + public var coordinator: NSPersistentStoreCoordinator { + container.persistentStoreCoordinator + } +} + +// MARK: Development + +extension CoreDataPersistentStore { public var containerURLs: [URL]? { guard let url = container.persistentStoreDescriptions.first?.url else { return nil @@ -104,13 +123,3 @@ public final class CoreDataPersistentStore { } } } - -extension CoreDataPersistentStore { - public var context: NSManagedObjectContext { - container.viewContext - } - - public var coordinator: NSPersistentStoreCoordinator { - container.persistentStoreCoordinator - } -} diff --git a/PassepartoutLibrary/Sources/PassepartoutVPNImpl/Data/VPNPersistence.swift b/PassepartoutLibrary/Sources/PassepartoutVPNImpl/Data/VPNPersistence.swift index 89eeb61c..0855ad88 100644 --- a/PassepartoutLibrary/Sources/PassepartoutVPNImpl/Data/VPNPersistence.swift +++ b/PassepartoutLibrary/Sources/PassepartoutVPNImpl/Data/VPNPersistence.swift @@ -23,6 +23,7 @@ // along with Passepartout. If not, see . // +import CloudKit import CoreData import Foundation import PassepartoutCore @@ -54,4 +55,15 @@ public final class VPNPersistence { public func profileRepository() -> ProfileRepository { CDProfileRepository(store.context) } + + // WARNING: this is not running on main actor + public func eraseCloudKitStore(fromContainerWithId containerId: String, zoneId: CKRecordZone.ID) async { + do { + let container = CKContainer(identifier: containerId) + let db = container.privateCloudDatabase + try await db.deleteRecordZone(withID: zoneId) + } catch { + pp_log.error("Unable to erase CloudKit store: \(error)") + } + } }