From 1491766102785a1c4c6ce7c825d0f5c61d143668 Mon Sep 17 00:00:00 2001 From: Davide Date: Thu, 3 Oct 2024 18:41:27 +0200 Subject: [PATCH] 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 --- Passepartout/App/App.entitlements | 21 ++- Passepartout/App/App.plist | 12 ++ Passepartout/Config.xcconfig | 4 + .../xcschemes/IntentsLibrary.xcscheme | 67 --------- Passepartout/Library/Package.swift | 17 ++- .../AppDataProfiles/AppData+Profiles.swift | 8 +- ...tory.swift => CDProfileRepositoryV3.swift} | 11 +- .../{CDProfile.swift => CDProfileV3.swift} | 6 +- .../Profiles.xcdatamodeld/.xccurrentversion | 8 ++ .../Profiles.xcdatamodel/contents | 4 +- .../ProfilesV3.xcdatamodel/contents | 9 ++ .../AppLibrary/Business/ProfileManager.swift | 129 +++++++++++++----- .../AppUI/Business/ProfileEditor.swift | 9 +- .../Sources/AppUI/L10n/SwiftGen+Strings.swift | 16 +++ .../Library/Sources/AppUI/Mock/Mock.swift | 1 + .../Resources/en.lproj/Localizable.strings | 4 + .../Views/App/AppInlineCoordinator.swift | 2 +- .../AppUI/Views/App/AppModalCoordinator.swift | 2 +- .../Profile/iOS/ProfileEditView+iOS.swift | 2 +- .../macOS/ProfileGeneralView+macOS.swift | 2 +- .../AppUI/Views/Theme/Theme+ImageName.swift | 1 + .../Sources/AppUI/Views/Theme/Theme.swift | 1 + .../AppUI/Views/UI/ProfileCardView.swift | 37 +++-- .../AppUI/Views/UI/ProfileRowView.swift | 10 +- .../AppUI/Views/UI/StorageSection.swift | 49 +++++-- .../Domain/BundleConfiguration+Main.swift | 10 ++ .../Library/Sources/LegacyV2/CDProfile.swift | 16 +++ .../LegacyV2/CDProfileRepositoryV2.swift | 71 ++++++++++ .../Library/Sources/LegacyV2/LegacyV2.swift | 54 ++++++++ .../Profiles.xcdatamodeld/.xccurrentversion | 8 ++ .../Profiles.xcdatamodel/contents | 11 ++ .../Business/CoreDataPersistentStore.swift | 7 +- Passepartout/Shared/Shared+AppLibrary.swift | 24 +++- 33 files changed, 473 insertions(+), 160 deletions(-) delete mode 100644 Passepartout/Library/.swiftpm/xcode/xcshareddata/xcschemes/IntentsLibrary.xcscheme rename Passepartout/Library/Sources/AppDataProfiles/{CDProfileRepository.swift => CDProfileRepositoryV3.swift} (88%) rename Passepartout/Library/Sources/AppDataProfiles/{CDProfile.swift => CDProfileV3.swift} (92%) create mode 100644 Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/.xccurrentversion create mode 100644 Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/ProfilesV3.xcdatamodel/contents create mode 100644 Passepartout/Library/Sources/LegacyV2/CDProfile.swift create mode 100644 Passepartout/Library/Sources/LegacyV2/CDProfileRepositoryV2.swift create mode 100644 Passepartout/Library/Sources/LegacyV2/LegacyV2.swift create mode 100644 Passepartout/Library/Sources/LegacyV2/Profiles.xcdatamodeld/.xccurrentversion create mode 100644 Passepartout/Library/Sources/LegacyV2/Profiles.xcdatamodeld/Profiles.xcdatamodel/contents diff --git a/Passepartout/App/App.entitlements b/Passepartout/App/App.entitlements index 090aad11..3beadaec 100644 --- a/Passepartout/App/App.entitlements +++ b/Passepartout/App/App.entitlements @@ -2,26 +2,39 @@ + aps-environment + development + com.apple.developer.aps-environment + development + com.apple.developer.icloud-container-identifiers + + $(CFG_CLOUDKIT_ID) + $(CFG_LEGACY_V2_CLOUDKIT_ID) + + com.apple.developer.icloud-services + + CloudKit + com.apple.developer.networking.networkextension packet-tunnel-provider com.apple.developer.networking.wifi-info + com.apple.security.app-sandbox + com.apple.security.application-groups $(CFG_GROUP_ID) - com.apple.security.app-sandbox - com.apple.security.files.user-selected.read-only - com.apple.security.personal-information.location - com.apple.security.network.client com.apple.security.network.server + com.apple.security.personal-information.location + keychain-access-groups $(AppIdentifierPrefix)$(CFG_GROUP_ID) diff --git a/Passepartout/App/App.plist b/Passepartout/App/App.plist index 023681b7..142827ed 100644 --- a/Passepartout/App/App.plist +++ b/Passepartout/App/App.plist @@ -6,6 +6,8 @@ appStoreId $(CFG_APP_STORE_ID) + cloudKitId + $(CFG_CLOUDKIT_ID) groupId $(CFG_GROUP_ID) iapBundlePrefix @@ -14,8 +16,14 @@ $(CFG_TEAM_ID).$(CFG_GROUP_ID) profilesContainerName $(CFG_PROFILES_CONTAINER_NAME) + remoteProfilesContainerName + $(CFG_PROFILES_CONTAINER_NAME).remote tunnelId $(CFG_TUNNEL_ID) + legacyV2CloudKitId + $(CFG_LEGACY_V2_CLOUDKIT_ID) + legacyV2ProfilesContainerName + $(CFG_LEGACY_V2_PROFILES_CONTAINER_NAME) CFBundleDocumentTypes @@ -52,5 +60,9 @@ CustomIntentIntent + UIBackgroundModes + + remote-notification + diff --git a/Passepartout/Config.xcconfig b/Passepartout/Config.xcconfig index c4ee640b..3c6d1eea 100644 --- a/Passepartout/Config.xcconfig +++ b/Passepartout/Config.xcconfig @@ -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) diff --git a/Passepartout/Library/.swiftpm/xcode/xcshareddata/xcschemes/IntentsLibrary.xcscheme b/Passepartout/Library/.swiftpm/xcode/xcshareddata/xcschemes/IntentsLibrary.xcscheme deleted file mode 100644 index 497600ff..00000000 --- a/Passepartout/Library/.swiftpm/xcode/xcshareddata/xcschemes/IntentsLibrary.xcscheme +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Passepartout/Library/Package.swift b/Passepartout/Library/Package.swift index 9f48a3e7..2766677e 100644 --- a/Passepartout/Library/Package.swift +++ b/Passepartout/Library/Package.swift @@ -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" ), diff --git a/Passepartout/Library/Sources/AppDataProfiles/AppData+Profiles.swift b/Passepartout/Library/Sources/AppDataProfiles/AppData+Profiles.swift index e80a2c7b..6e0f516f 100644 --- a/Passepartout/Library/Sources/AppDataProfiles/AppData+Profiles.swift +++ b/Passepartout/Library/Sources/AppDataProfiles/AppData+Profiles.swift @@ -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 - }() + } } diff --git a/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepository.swift b/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift similarity index 88% rename from Passepartout/Library/Sources/AppDataProfiles/CDProfileRepository.swift rename to Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift index 0598c9db..c26dbef9 100644 --- a/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepository.swift +++ b/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift @@ -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(context: context) { + let repository = CoreDataRepository(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 { diff --git a/Passepartout/Library/Sources/AppDataProfiles/CDProfile.swift b/Passepartout/Library/Sources/AppDataProfiles/CDProfileV3.swift similarity index 92% rename from Passepartout/Library/Sources/AppDataProfiles/CDProfile.swift rename to Passepartout/Library/Sources/AppDataProfiles/CDProfileV3.swift index df79911f..05bb6f31 100644 --- a/Passepartout/Library/Sources/AppDataProfiles/CDProfile.swift +++ b/Passepartout/Library/Sources/AppDataProfiles/CDProfileV3.swift @@ -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? diff --git a/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/.xccurrentversion b/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/.xccurrentversion new file mode 100644 index 00000000..f2ff7746 --- /dev/null +++ b/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/.xccurrentversion @@ -0,0 +1,8 @@ + + + + + _XCCurrentVersionName + ProfilesV3.xcdatamodel + + diff --git a/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/Profiles.xcdatamodel/contents b/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/Profiles.xcdatamodel/contents index 0256120b..f7dbaa9b 100644 --- a/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/Profiles.xcdatamodel/contents +++ b/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/Profiles.xcdatamodel/contents @@ -1,6 +1,6 @@ - - + + diff --git a/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/ProfilesV3.xcdatamodel/contents b/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/ProfilesV3.xcdatamodel/contents new file mode 100644 index 00000000..c4707a12 --- /dev/null +++ b/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/ProfilesV3.xcdatamodel/contents @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/Passepartout/Library/Sources/AppLibrary/Business/ProfileManager.swift b/Passepartout/Library/Sources/AppLibrary/Business/ProfileManager.swift index fd308596..c94a5520 100644 --- a/Passepartout/Library/Sources/AppLibrary/Business/ProfileManager.swift +++ b/Passepartout/Library/Sources/AppLibrary/Business/ProfileManager.swift @@ -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 + private let repository: any ProfileRepository + + private let remoteRepository: (any ProfileRepository)? @Published private var profiles: [Profile] - private var allProfileIds: Set + private var allProfiles: [Profile.ID: Profile] { + didSet { + reloadFilteredProfiles() + } + } - private let repository: any ProfileRepository + private var allRemoteProfiles: [Profile.ID: Profile] + + public let didChange: PassthroughSubject private let searchSubject: CurrentValueSubject @@ -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) { - let oldProfiles = profiles.reduce(into: [:]) { + func notifyLocalEntities(_ result: EntitiesResult) { + 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) { + 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) - } } } diff --git a/Passepartout/Library/Sources/AppUI/Business/ProfileEditor.swift b/Passepartout/Library/Sources/AppUI/Business/ProfileEditor.swift index 4cf78d6c..a7cf5822 100644 --- a/Passepartout/Library/Sources/AppUI/Business/ProfileEditor.swift +++ b/Passepartout/Library/Sources/AppUI/Business/ProfileEditor.swift @@ -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 diff --git a/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift b/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift index 14b12f24..76f46fd8 100644 --- a/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift +++ b/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift @@ -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 diff --git a/Passepartout/Library/Sources/AppUI/Mock/Mock.swift b/Passepartout/Library/Sources/AppUI/Mock/Mock.swift index ec83b959..ca3368ae 100644 --- a/Passepartout/Library/Sources/AppUI/Mock/Mock.swift +++ b/Passepartout/Library/Sources/AppUI/Mock/Mock.swift @@ -41,6 +41,7 @@ extension AppContext { iapManager: IAPManager( customUserLevel: nil, receiptReader: MockReceiptReader(), + unrestrictedFeatures: [.sharing], productsAtBuild: { _ in [] } diff --git a/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings b/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings index 57ebda04..7f7f80ed 100644 --- a/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings @@ -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"; diff --git a/Passepartout/Library/Sources/AppUI/Views/App/AppInlineCoordinator.swift b/Passepartout/Library/Sources/AppUI/Views/App/AppInlineCoordinator.swift index f11a5b09..bcf6d2fc 100644 --- a/Passepartout/Library/Sources/AppUI/Views/App/AppInlineCoordinator.swift +++ b/Passepartout/Library/Sources/AppUI/Views/App/AppInlineCoordinator.swift @@ -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) } diff --git a/Passepartout/Library/Sources/AppUI/Views/App/AppModalCoordinator.swift b/Passepartout/Library/Sources/AppUI/Views/App/AppModalCoordinator.swift index 57e0ce56..e6a8e5b5 100644 --- a/Passepartout/Library/Sources/AppUI/Views/App/AppModalCoordinator.swift +++ b/Passepartout/Library/Sources/AppUI/Views/App/AppModalCoordinator.swift @@ -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 } } diff --git a/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift b/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift index 127be364..0df60d86 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift @@ -64,7 +64,7 @@ struct ProfileEditView: View, Routable { Text(Strings.Views.Profile.ModuleList.Section.footer) } StorageSection( - uuid: profileEditor.id + profileEditor: profileEditor ) } .toolbar(content: toolbarContent) diff --git a/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ProfileGeneralView+macOS.swift b/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ProfileGeneralView+macOS.swift index 7132fc15..a56427c1 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ProfileGeneralView+macOS.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ProfileGeneralView+macOS.swift @@ -39,7 +39,7 @@ struct ProfileGeneralView: View { placeholder: Strings.Placeholders.Profile.name ) StorageSection( - uuid: profileEditor.id + profileEditor: profileEditor ) } .themeForm() diff --git a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+ImageName.swift b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+ImageName.swift index 1dbf1b14..9aba8129 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+ImageName.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+ImageName.swift @@ -29,6 +29,7 @@ extension Theme { public enum ImageName { case add case close + case cloud case contextDuplicate case contextRemove case copy diff --git a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme.swift b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme.swift index 0a2e05b2..4a8a3517 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme.swift @@ -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" diff --git a/Passepartout/Library/Sources/AppUI/Views/UI/ProfileCardView.swift b/Passepartout/Library/Sources/AppUI/Views/UI/ProfileCardView.swift index f0b8bc81..dcdd3a25 100644 --- a/Passepartout/Library/Sources/AppUI/Views/UI/ProfileCardView.swift +++ b/Passepartout/Library/Sources/AppUI/Views/UI/ProfileCardView.swift @@ -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() } diff --git a/Passepartout/Library/Sources/AppUI/Views/UI/ProfileRowView.swift b/Passepartout/Library/Sources/AppUI/Views/UI/ProfileRowView.swift index 9a7b92e4..6f29d24c 100644 --- a/Passepartout/Library/Sources/AppUI/Views/UI/ProfileRowView.swift +++ b/Passepartout/Library/Sources/AppUI/Views/UI/ProfileRowView.swift @@ -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) } } diff --git a/Passepartout/Library/Sources/AppUI/Views/UI/StorageSection.swift b/Passepartout/Library/Sources/AppUI/Views/UI/StorageSection.swift index ab319a1c..b9ec5d49 100644 --- a/Passepartout/Library/Sources/AppUI/Views/UI/StorageSection.swift +++ b/Passepartout/Library/Sources/AppUI/Views/UI/StorageSection.swift @@ -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() diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+Main.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+Main.swift index f7fcb427..d598f562 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+Main.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+Main.swift @@ -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 { diff --git a/Passepartout/Library/Sources/LegacyV2/CDProfile.swift b/Passepartout/Library/Sources/LegacyV2/CDProfile.swift new file mode 100644 index 00000000..781f3bdd --- /dev/null +++ b/Passepartout/Library/Sources/LegacyV2/CDProfile.swift @@ -0,0 +1,16 @@ +import CoreData +import Foundation + +@objc(CDProfile) +final class CDProfile: NSManagedObject { + @nonobjc static func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(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? +} diff --git a/Passepartout/Library/Sources/LegacyV2/CDProfileRepositoryV2.swift b/Passepartout/Library/Sources/LegacyV2/CDProfileRepositoryV2.swift new file mode 100644 index 00000000..677ec528 --- /dev/null +++ b/Passepartout/Library/Sources/LegacyV2/CDProfileRepositoryV2.swift @@ -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 . +// + +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 + } + } + } +} diff --git a/Passepartout/Library/Sources/LegacyV2/LegacyV2.swift b/Passepartout/Library/Sources/LegacyV2/LegacyV2.swift new file mode 100644 index 00000000..4463efa3 --- /dev/null +++ b/Passepartout/Library/Sources/LegacyV2/LegacyV2.swift @@ -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 . +// + +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() + } +} diff --git a/Passepartout/Library/Sources/LegacyV2/Profiles.xcdatamodeld/.xccurrentversion b/Passepartout/Library/Sources/LegacyV2/Profiles.xcdatamodeld/.xccurrentversion new file mode 100644 index 00000000..b47e4a93 --- /dev/null +++ b/Passepartout/Library/Sources/LegacyV2/Profiles.xcdatamodeld/.xccurrentversion @@ -0,0 +1,8 @@ + + + + + _XCCurrentVersionName + Profiles.xcdatamodel + + diff --git a/Passepartout/Library/Sources/LegacyV2/Profiles.xcdatamodeld/Profiles.xcdatamodel/contents b/Passepartout/Library/Sources/LegacyV2/Profiles.xcdatamodeld/Profiles.xcdatamodel/contents new file mode 100644 index 00000000..347ee32b --- /dev/null +++ b/Passepartout/Library/Sources/LegacyV2/Profiles.xcdatamodeld/Profiles.xcdatamodel/contents @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/Passepartout/Library/Sources/UtilsLibrary/Business/CoreDataPersistentStore.swift b/Passepartout/Library/Sources/UtilsLibrary/Business/CoreDataPersistentStore.swift index ae4a51cf..335a24db 100644 --- a/Passepartout/Library/Sources/UtilsLibrary/Business/CoreDataPersistentStore.swift +++ b/Passepartout/Library/Sources/UtilsLibrary/Business/CoreDataPersistentStore.swift @@ -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 } diff --git a/Passepartout/Shared/Shared+AppLibrary.swift b/Passepartout/Shared/Shared+AppLibrary.swift index 44b3a178..81209055 100644 --- a/Passepartout/Shared/Shared+AppLibrary.swift +++ b/Passepartout/Shared/Shared+AppLibrary.swift @@ -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) }() }