From b8294a4501bb25899c55a544266a698b25d047aa Mon Sep 17 00:00:00 2001 From: Davide Date: Thu, 19 Dec 2024 18:31:08 +0100 Subject: [PATCH 1/3] Enable Core Data lightweight migration --- .../CommonUtils/Business/CoreDataPersistentStore.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Library/Sources/CommonUtils/Business/CoreDataPersistentStore.swift b/Library/Sources/CommonUtils/Business/CoreDataPersistentStore.swift index b7416905e..d537573b2 100644 --- a/Library/Sources/CommonUtils/Business/CoreDataPersistentStore.swift +++ b/Library/Sources/CommonUtils/Business/CoreDataPersistentStore.swift @@ -90,6 +90,10 @@ public final class CoreDataPersistentStore: Sendable { // container was formerly created with CloudKit option desc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) + // migrate automatically + desc.setOption(true as NSNumber, forKey: NSMigratePersistentStoresAutomaticallyOption) + desc.setOption(true as NSNumber, forKey: NSInferMappingModelAutomaticallyOption) + // report remote notifications (do this BEFORE loadPersistentStores) // // https://stackoverflow.com/a/69507329/784615 From 745daf10cb1e1b33f77fe3ec6d4fec12532fde88 Mon Sep 17 00:00:00 2001 From: Davide Date: Thu, 19 Dec 2024 19:08:03 +0100 Subject: [PATCH 2/3] Simplify results controller creation --- .../Business/CoreDataRepository.swift | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/Library/Sources/CommonUtils/Business/CoreDataRepository.swift b/Library/Sources/CommonUtils/Business/CoreDataRepository.swift index 759021705..6774b4030 100644 --- a/Library/Sources/CommonUtils/Business/CoreDataRepository.swift +++ b/Library/Sources/CommonUtils/Business/CoreDataRepository.swift @@ -180,20 +180,19 @@ private extension CoreDataRepository { request.predicate = predicate beforeFetch?(request) - let newController = try await context.perform { - let newController = NSFetchedResultsController( - fetchRequest: request, - managedObjectContext: self.context, - sectionNameKeyPath: nil, - cacheName: nil - ) - newController.delegate = self + let newController = NSFetchedResultsController( + fetchRequest: request, + managedObjectContext: self.context, + sectionNameKeyPath: nil, + cacheName: nil + ) + newController.delegate = self + resultsController = newController + + return try await context.perform { try newController.performFetch() - return newController + return self.unsafeSendResults(from: newController) } - - resultsController = newController - return await sendResults(from: newController) } @discardableResult From 8c947ff2e69cf9862be45499756c7dd8e993917c Mon Sep 17 00:00:00 2001 From: Davide Date: Fri, 20 Dec 2024 09:48:10 +0100 Subject: [PATCH 3/3] Reload remote container onEligibleFeatures() The remote container is shared by ProfileManager and PreferencesManager, but it must be the same for CloudKit sync to work properly. Externalize the logic of onEligibleFeatures() so that the AppContext singleton can update the managers (and their repositories) with the new remote store. Now that the remote profile repository is reloaded every time that eligible features change, the .removeDuplicates() may also be restored. Just add a .dropFirst() to skip the initially empty value of eligible features. --- .../AppUIMain/Views/App/ProfileRowView.swift | 2 +- .../Business/PreferencesManager.swift | 17 +- .../Business/ProfileManager.swift | 43 ++--- .../UILibrary/Business/AppContext.swift | 47 +---- .../Business/ProfileManagerTests.swift | 90 ++++----- .../App/Context/AppContext+Shared.swift | 175 +++++++++++------- .../App/Context/ProfileManager+Testing.swift | 6 +- .../Shared/Dependencies+PassepartoutKit.swift | 6 +- 8 files changed, 175 insertions(+), 211 deletions(-) diff --git a/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift b/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift index 9a0a51706..dc26b131e 100644 --- a/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift +++ b/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift @@ -212,7 +212,7 @@ private extension ProfileRowView { } .task { do { - try await profileManager.observeRemote(true) + try await profileManager.observeRemote(repository: InMemoryProfileRepository()) try await profileManager.save(profile, isLocal: true, remotelyShared: true) } catch { fatalError(error.localizedDescription) diff --git a/Library/Sources/CommonLibrary/Business/PreferencesManager.swift b/Library/Sources/CommonLibrary/Business/PreferencesManager.swift index 803187026..84b6dfe45 100644 --- a/Library/Sources/CommonLibrary/Business/PreferencesManager.swift +++ b/Library/Sources/CommonLibrary/Business/PreferencesManager.swift @@ -29,18 +29,15 @@ import PassepartoutKit @MainActor public final class PreferencesManager: ObservableObject { - private let modulesFactory: (UUID) throws -> ModulePreferencesRepository + public var modulesRepositoryFactory: (UUID) throws -> ModulePreferencesRepository - private let providersFactory: (ProviderID) throws -> ProviderPreferencesRepository + public var providersRepositoryFactory: (ProviderID) throws -> ProviderPreferencesRepository - public init( - modulesFactory: ((UUID) throws -> ModulePreferencesRepository)? = nil, - providersFactory: ((ProviderID) throws -> ProviderPreferencesRepository)? = nil - ) { - self.modulesFactory = modulesFactory ?? { _ in + public init() { + modulesRepositoryFactory = { _ in DummyModulePreferencesRepository() } - self.providersFactory = providersFactory ?? { _ in + providersRepositoryFactory = { _ in DummyProviderPreferencesRepository() } } @@ -48,11 +45,11 @@ public final class PreferencesManager: ObservableObject { extension PreferencesManager { public func preferencesRepository(forModuleWithId moduleId: UUID) throws -> ModulePreferencesRepository { - try modulesFactory(moduleId) + try modulesRepositoryFactory(moduleId) } public func preferencesRepository(forProviderWithId providerId: ProviderID) throws -> ProviderPreferencesRepository { - try providersFactory(providerId) + try providersRepositoryFactory(providerId) } } diff --git a/Library/Sources/CommonLibrary/Business/ProfileManager.swift b/Library/Sources/CommonLibrary/Business/ProfileManager.swift index 28db6f3ec..048e47dfc 100644 --- a/Library/Sources/CommonLibrary/Business/ProfileManager.swift +++ b/Library/Sources/CommonLibrary/Business/ProfileManager.swift @@ -59,8 +59,6 @@ public final class ProfileManager: ObservableObject { private let backupRepository: ProfileRepository? - private let remoteRepositoryBlock: ((Bool) -> ProfileRepository)? - private var remoteRepository: ProfileRepository? private let mirrorsRemoteRepository: Bool @@ -94,7 +92,7 @@ public final class ProfileManager: ObservableObject { private var requiredFeatures: [Profile.ID: Set] @Published - public private(set) var isRemoteImportingEnabled: Bool + public var isRemoteImportingEnabled = false private var waitingObservers: Set { didSet { @@ -120,34 +118,25 @@ public final class ProfileManager: ObservableObject { // for testing/previews public convenience init(profiles: [Profile]) { - self.init( - repository: InMemoryProfileRepository(profiles: profiles), - remoteRepositoryBlock: { _ in - InMemoryProfileRepository() - } - ) + self.init(repository: InMemoryProfileRepository(profiles: profiles)) } public init( + processor: ProfileProcessor? = nil, repository: ProfileRepository, backupRepository: ProfileRepository? = nil, - remoteRepositoryBlock: ((Bool) -> ProfileRepository)?, - mirrorsRemoteRepository: Bool = false, - processor: ProfileProcessor? = nil + mirrorsRemoteRepository: Bool = false ) { - precondition(!mirrorsRemoteRepository || remoteRepositoryBlock != nil, "mirrorsRemoteRepository requires a non-nil remoteRepositoryBlock") + self.processor = processor self.repository = repository self.backupRepository = backupRepository - self.remoteRepositoryBlock = remoteRepositoryBlock self.mirrorsRemoteRepository = mirrorsRemoteRepository - self.processor = processor allProfiles = [:] allRemoteProfiles = [:] filteredProfiles = [] requiredFeatures = [:] - isRemoteImportingEnabled = false - if remoteRepositoryBlock != nil { + if mirrorsRemoteRepository { waitingObservers = [.local, .remote] } else { waitingObservers = [.local] @@ -341,24 +330,13 @@ extension ProfileManager { } } - public func observeRemote(_ isRemoteImportingEnabled: Bool) async throws { - guard let remoteRepositoryBlock else { -// preconditionFailure("Missing remoteRepositoryBlock") - return - } - guard remoteRepository == nil || isRemoteImportingEnabled != self.isRemoteImportingEnabled else { - return - } - - self.isRemoteImportingEnabled = isRemoteImportingEnabled - + public func observeRemote(repository: ProfileRepository) async throws { remoteSubscription = nil - let newRepository = remoteRepositoryBlock(isRemoteImportingEnabled) - let initialProfiles = try await newRepository.fetchProfiles() + remoteRepository = repository + let initialProfiles = try await repository.fetchProfiles() reloadRemoteProfiles(initialProfiles) - remoteRepository = newRepository - remoteSubscription = remoteRepository? + remoteSubscription = repository .profilesPublisher .dropFirst() .receive(on: DispatchQueue.main) @@ -422,6 +400,7 @@ private extension ProfileManager { if waitingObservers.contains(.remote) { waitingObservers.remove(.remote) } + Task { [weak self] in self?.didChange.send(.startRemoteImport) await self?.importRemoteProfiles(result) diff --git a/Library/Sources/UILibrary/Business/AppContext.swift b/Library/Sources/UILibrary/Business/AppContext.swift index 0000cc09c..5739e95ce 100644 --- a/Library/Sources/UILibrary/Business/AppContext.swift +++ b/Library/Sources/UILibrary/Business/AppContext.swift @@ -48,6 +48,8 @@ public final class AppContext: ObservableObject, Sendable { private let tunnelReceiptURL: URL? + private let onEligibleFeaturesBlock: ((Set) async -> Void)? + private var launchTask: Task? private var pendingTask: Task? @@ -62,7 +64,8 @@ public final class AppContext: ObservableObject, Sendable { preferencesManager: PreferencesManager, registry: Registry, tunnel: ExtendedTunnel, - tunnelReceiptURL: URL? + tunnelReceiptURL: URL?, + onEligibleFeaturesBlock: ((Set) async -> Void)? = nil ) { self.iapManager = iapManager self.migrationManager = migrationManager @@ -72,6 +75,7 @@ public final class AppContext: ObservableObject, Sendable { self.registry = registry self.tunnel = tunnel self.tunnelReceiptURL = tunnelReceiptURL + self.onEligibleFeaturesBlock = onEligibleFeaturesBlock subscriptions = [] } } @@ -99,22 +103,16 @@ private extension AppContext { pp_log(.App.profiles, .info, "\tObserve in-app events...") iapManager.observeObjects() - // load in background, see comment right below + // defer load receipt Task { await iapManager.reloadReceipt() } - // using Task above (#1019) causes the receipt to be loaded asynchronously. - // the initial call to onEligibleFeatures() may execute before the receipt is - // loaded and therefore do nothing. with .removeDuplicates(), there would - // not be a second chance to call onEligibleFeatures() if the eligible - // features haven't changed after reloading the receipt (this is the case - // for TestFlight where some features are set statically). that's why it's - // commented now pp_log(.App.profiles, .info, "\tObserve eligible features...") iapManager .$eligibleFeatures -// .removeDuplicates() + .dropFirst() + .removeDuplicates() .sink { [weak self] eligible in Task { try await self?.onEligibleFeatures(eligible) @@ -184,19 +182,7 @@ private extension AppContext { pp_log(.app, .notice, "Application did update eligible features") pendingTask = Task { - - // toggle sync based on .sharing eligibility - let isEligibleForSharing = features.contains(.sharing) - do { - pp_log(.App.profiles, .info, "\tRefresh remote profiles observers (eligible=\(isEligibleForSharing), CloudKit=\(isCloudKitEnabled))...") - try await profileManager.observeRemote(isEligibleForSharing && isCloudKitEnabled) - } catch { - pp_log(.App.profiles, .error, "\tUnable to re-observe remote profiles: \(error)") - } - - // refresh required profile features - pp_log(.App.profiles, .info, "\tReload profiles required features...") - profileManager.reloadRequiredFeatures() + await onEligibleFeaturesBlock?(features) } await pendingTask?.value pendingTask = nil @@ -262,18 +248,3 @@ private extension AppContext { return didLaunch } } - -// MARK: - Helpers - -private extension AppContext { - var isCloudKitEnabled: Bool { -#if os(tvOS) - true -#else - if AppCommandLine.contains(.uiTesting) { - return true - } - return FileManager.default.ubiquityIdentityToken != nil -#endif - } -} diff --git a/Library/Tests/CommonLibraryTests/Business/ProfileManagerTests.swift b/Library/Tests/CommonLibraryTests/Business/ProfileManagerTests.swift index 59d5300d1..73e75e2da 100644 --- a/Library/Tests/CommonLibraryTests/Business/ProfileManagerTests.swift +++ b/Library/Tests/CommonLibraryTests/Business/ProfileManagerTests.swift @@ -50,7 +50,7 @@ extension ProfileManagerTests { func test_givenRepository_whenNotReady_thenHasNoProfiles() { let repository = InMemoryProfileRepository(profiles: []) - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil) + let sut = ProfileManager(repository: repository) XCTAssertFalse(sut.isReady) XCTAssertFalse(sut.hasProfiles) XCTAssertTrue(sut.previews.isEmpty) @@ -59,7 +59,7 @@ extension ProfileManagerTests { func test_givenRepository_whenReady_thenHasProfiles() async throws { let profile = newProfile() let repository = InMemoryProfileRepository(profiles: [profile]) - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil) + let sut = ProfileManager(repository: repository) try await waitForReady(sut) XCTAssertTrue(sut.isReady) @@ -72,7 +72,7 @@ extension ProfileManagerTests { let profile1 = newProfile("foo") let profile2 = newProfile("bar") let repository = InMemoryProfileRepository(profiles: [profile1, profile2]) - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil) + let sut = ProfileManager(repository: repository) try await waitForReady(sut) XCTAssertTrue(sut.isReady) @@ -93,7 +93,7 @@ extension ProfileManagerTests { let repository = InMemoryProfileRepository(profiles: [profile]) let processor = MockProfileProcessor() processor.requiredFeatures = [.appleTV, .onDemand] - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil, processor: processor) + let sut = ProfileManager(processor: processor, repository: repository) try await waitForReady(sut) XCTAssertTrue(sut.isReady) @@ -115,7 +115,7 @@ extension ProfileManagerTests { processor.isIncludedBlock = { $0.name == "local2" } - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil, processor: processor) + let sut = ProfileManager(processor: processor, repository: repository) try await waitForReady(sut) XCTAssertTrue(sut.isReady) @@ -129,7 +129,7 @@ extension ProfileManagerTests { let repository = InMemoryProfileRepository(profiles: [profile]) let processor = MockProfileProcessor() processor.requiredFeatures = [.appleTV, .onDemand] - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil, processor: processor) + let sut = ProfileManager(processor: processor, repository: repository) try await waitForReady(sut) XCTAssertTrue(sut.isReady) @@ -155,7 +155,7 @@ extension ProfileManagerTests { extension ProfileManagerTests { func test_givenRepository_whenSave_thenIsSaved() async throws { let repository = InMemoryProfileRepository(profiles: []) - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil) + let sut = ProfileManager(repository: repository) try await waitForReady(sut) XCTAssertTrue(sut.isReady) @@ -172,7 +172,7 @@ extension ProfileManagerTests { func test_givenRepository_whenSaveExisting_thenIsReplaced() async throws { let profile = newProfile("oldName") let repository = InMemoryProfileRepository(profiles: [profile]) - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil) + let sut = ProfileManager(repository: repository) try await waitForReady(sut) XCTAssertTrue(sut.isReady) @@ -191,7 +191,7 @@ extension ProfileManagerTests { func test_givenRepositoryAndProcessor_whenSave_thenProcessorIsNotInvoked() async throws { let repository = InMemoryProfileRepository(profiles: []) let processor = MockProfileProcessor() - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil, processor: processor) + let sut = ProfileManager(processor: processor, repository: repository) try await waitForReady(sut) XCTAssertTrue(sut.isReady) @@ -207,7 +207,7 @@ extension ProfileManagerTests { func test_givenRepositoryAndProcessor_whenSaveLocal_thenProcessorIsInvoked() async throws { let repository = InMemoryProfileRepository(profiles: []) let processor = MockProfileProcessor() - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil, processor: processor) + let sut = ProfileManager(processor: processor, repository: repository) try await waitForReady(sut) XCTAssertTrue(sut.isReady) @@ -221,7 +221,7 @@ extension ProfileManagerTests { func test_givenRepository_whenSave_thenIsStoredToBackUpRepository() async throws { let repository = InMemoryProfileRepository(profiles: []) let backupRepository = InMemoryProfileRepository(profiles: []) - let sut = ProfileManager(repository: repository, backupRepository: backupRepository, remoteRepositoryBlock: nil) + let sut = ProfileManager(repository: repository, backupRepository: backupRepository) try await waitForReady(sut) XCTAssertTrue(sut.isReady) @@ -247,7 +247,7 @@ extension ProfileManagerTests { func test_givenRepository_whenRemove_thenIsRemoved() async throws { let profile = newProfile() let repository = InMemoryProfileRepository(profiles: [profile]) - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil) + let sut = ProfileManager(repository: repository) try await waitForReady(sut) XCTAssertTrue(sut.isReady) @@ -267,11 +267,9 @@ extension ProfileManagerTests { let profile = newProfile() let repository = InMemoryProfileRepository() let remoteRepository = InMemoryProfileRepository() - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in - remoteRepository - }) + let sut = ProfileManager(repository: repository) - try await waitForReady(sut) + try await waitForReady(sut, remoteRepository: remoteRepository) let exp = expectation(description: "Remote") remoteRepository @@ -291,15 +289,13 @@ extension ProfileManagerTests { XCTAssertTrue(sut.isRemotelyShared(profileWithId: profile.id)) } - func test_givenRemoteRepository_whenSaveNotRemotelyShared_thenIsRemoveFromRemoteRepository() async throws { + func test_givenRemoteRepository_whenSaveNotRemotelyShared_thenIsRemovedFromRemoteRepository() async throws { let profile = newProfile() let repository = InMemoryProfileRepository(profiles: [profile]) let remoteRepository = InMemoryProfileRepository(profiles: [profile]) - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in - remoteRepository - }) + let sut = ProfileManager(repository: repository) - try await waitForReady(sut) + try await waitForReady(sut, remoteRepository: remoteRepository) let exp = expectation(description: "Remote") remoteRepository @@ -325,7 +321,7 @@ extension ProfileManagerTests { func test_givenRepository_whenNew_thenReturnsProfileWithNewName() async throws { let profile = newProfile("example") let repository = InMemoryProfileRepository(profiles: [profile]) - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil) + let sut = ProfileManager(repository: repository) try await waitForReady(sut) XCTAssertEqual(sut.previews.count, 1) @@ -337,7 +333,7 @@ extension ProfileManagerTests { func test_givenRepository_whenDuplicate_thenSavesProfileWithNewName() async throws { let profile = newProfile("example") let repository = InMemoryProfileRepository(profiles: [profile]) - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil) + let sut = ProfileManager(repository: repository) try await waitForReady(sut) @@ -381,13 +377,11 @@ extension ProfileManagerTests { let allProfiles = localProfiles + remoteProfiles let repository = InMemoryProfileRepository(profiles: localProfiles) let remoteRepository = InMemoryProfileRepository(profiles: remoteProfiles) - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in - remoteRepository - }) + let sut = ProfileManager(repository: repository) try await wait(sut, "Remote import", until: .stopRemoteImport) { try await $0.observeLocal() - try await $0.observeRemote(true) + try await $0.observeRemote(repository: remoteRepository) } XCTAssertEqual(sut.previews.count, allProfiles.count) @@ -417,13 +411,11 @@ extension ProfileManagerTests { ] let repository = InMemoryProfileRepository(profiles: localProfiles) let remoteRepository = InMemoryProfileRepository(profiles: remoteProfiles) - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in - remoteRepository - }) + let sut = ProfileManager(repository: repository) try await wait(sut, "Remote import", until: .stopRemoteImport) { try await $0.observeLocal() - try await $0.observeRemote(true) + try await $0.observeRemote(repository: remoteRepository) } XCTAssertEqual(sut.previews.count, 4) // unique IDs @@ -464,13 +456,11 @@ extension ProfileManagerTests { processor.isIncludedBlock = { !$0.name.hasPrefix("remote") } - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in - remoteRepository - }, processor: processor) + let sut = ProfileManager(processor: processor, repository: repository) try await wait(sut, "Remote import", until: .stopRemoteImport) { try await $0.observeLocal() - try await $0.observeRemote(true) + try await $0.observeRemote(repository: remoteRepository) } XCTAssertEqual(processor.isIncludedCount, allProfiles.count) @@ -499,13 +489,11 @@ extension ProfileManagerTests { let repository = InMemoryProfileRepository(profiles: localProfiles) let remoteRepository = InMemoryProfileRepository(profiles: remoteProfiles) let processor = MockProfileProcessor() - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in - remoteRepository - }, processor: processor) + let sut = ProfileManager(processor: processor, repository: repository) try await wait(sut, "Remote import", until: .stopRemoteImport) { try await $0.observeLocal() - try await $0.observeRemote(true) + try await $0.observeRemote(repository: remoteRepository) } try sut.previews.forEach { @@ -531,13 +519,11 @@ extension ProfileManagerTests { ] let repository = InMemoryProfileRepository(profiles: localProfiles) let remoteRepository = InMemoryProfileRepository() - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in - remoteRepository - }) + let sut = ProfileManager(repository: repository) try await wait(sut, "Remote import", until: .stopRemoteImport) { try await $0.observeLocal() - try await $0.observeRemote(true) + try await $0.observeRemote(repository: remoteRepository) } XCTAssertEqual(sut.previews.count, localProfiles.count) @@ -589,13 +575,11 @@ extension ProfileManagerTests { let remoteProfiles = [profile] let repository = InMemoryProfileRepository(profiles: localProfiles) let remoteRepository = InMemoryProfileRepository(profiles: remoteProfiles) - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in - remoteRepository - }) + let sut = ProfileManager(repository: repository) try await wait(sut, "Remote import", until: .stopRemoteImport) { try await $0.observeLocal() - try await $0.observeRemote(true) + try await $0.observeRemote(repository: remoteRepository) } XCTAssertEqual(sut.previews.count, 1) @@ -611,13 +595,11 @@ extension ProfileManagerTests { let localProfiles = [profile] let repository = InMemoryProfileRepository(profiles: localProfiles) let remoteRepository = InMemoryProfileRepository(profiles: localProfiles) - let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in - remoteRepository - }, mirrorsRemoteRepository: true) + let sut = ProfileManager(repository: repository, mirrorsRemoteRepository: true) try await wait(sut, "Remote import", until: .stopRemoteImport) { try await $0.observeLocal() - try await $0.observeRemote(true) + try await $0.observeRemote(repository: remoteRepository) } XCTAssertEqual(sut.previews.count, 1) @@ -644,10 +626,12 @@ private extension ProfileManagerTests { } } - func waitForReady(_ sut: ProfileManager, importingRemote: Bool = true) async throws { + func waitForReady(_ sut: ProfileManager, remoteRepository: ProfileRepository? = nil) async throws { try await wait(sut, "Ready", until: .ready) { try await $0.observeLocal() - try await $0.observeRemote(importingRemote) + if let remoteRepository { + try await $0.observeRemote(repository: remoteRepository) + } } } diff --git a/Passepartout/App/Context/AppContext+Shared.swift b/Passepartout/App/Context/AppContext+Shared.swift index be338e1c5..a38d67c1f 100644 --- a/Passepartout/App/Context/AppContext+Shared.swift +++ b/Passepartout/App/Context/AppContext+Shared.swift @@ -40,18 +40,39 @@ extension AppContext { static let shared: AppContext = { let dependencies: Dependencies = .shared + // MARK: Core Data + + guard let cdLocalModel = NSManagedObjectModel.mergedModel(from: [ + AppData.providersBundle + ]) else { + fatalError("Unable to load local model") + } guard let cdRemoteModel = NSManagedObjectModel.mergedModel(from: [ AppData.profilesBundle, AppData.preferencesBundle ]) else { fatalError("Unable to load remote model") } - guard let cdLocalModel = NSManagedObjectModel.mergedModel(from: [ - AppData.providersBundle - ]) else { - fatalError("Unable to load local model") + + let localStore = CoreDataPersistentStore( + logger: dependencies.coreDataLogger(), + containerName: Constants.shared.containers.local, + model: cdLocalModel, + cloudKitIdentifier: nil, + author: nil + ) + let newRemoteStore: (_ cloudKit: Bool) -> CoreDataPersistentStore = { + CoreDataPersistentStore( + logger: dependencies.coreDataLogger(), + containerName: Constants.shared.containers.remote, + model: cdRemoteModel, + cloudKitIdentifier: $0 ? BundleConfiguration.mainString(for: .cloudKitId) : nil, + author: nil + ) } + // MARK: Managers + let iapManager = IAPManager( customUserLevel: dependencies.customUserLevel, inAppHelper: dependencies.simulatedAppProductHelper(), @@ -59,13 +80,13 @@ extension AppContext { betaChecker: dependencies.betaChecker(), productsAtBuild: dependencies.productsAtBuild() ) - let processor = dependencies.appProcessor(with: iapManager) - let tunnelEnvironment = dependencies.tunnelEnvironment() + let tunnelReceiptURL = BundleConfiguration.urlForBetaReceipt + let tunnelEnvironment = dependencies.tunnelEnvironment() #if targetEnvironment(simulator) let tunnelStrategy = FakeTunnelStrategy(environment: tunnelEnvironment, dataCountInterval: 1000) - let mainProfileRepository = dependencies.coreDataProfileRepository( + let mainProfileRepository = dependencies.backupProfileRepository( model: cdRemoteModel, observingResults: true ) @@ -80,36 +101,15 @@ extension AppContext { } #endif - let profileManager: ProfileManager = { - let remoteRepositoryBlock: (Bool) -> ProfileRepository = { - let remoteStore = CoreDataPersistentStore( - logger: dependencies.coreDataLogger(), - containerName: Constants.shared.containers.remote, - model: cdRemoteModel, - cloudKitIdentifier: $0 ? BundleConfiguration.mainString(for: .cloudKitId) : nil, - author: nil - ) - return AppData.cdProfileRepositoryV3( - registry: dependencies.registry, - coder: CodableProfileCoder(), - context: remoteStore.context, - observingResults: true, - onResultError: { - pp_log(.App.profiles, .error, "Unable to decode remote profile: \($0)") - return .ignore - } - ) - } - return ProfileManager( - repository: mainProfileRepository, - backupRepository: dependencies.backupProfileRepository( - model: cdRemoteModel - ), - remoteRepositoryBlock: remoteRepositoryBlock, - mirrorsRemoteRepository: dependencies.mirrorsRemoteRepository, - processor: processor - ) - }() + let profileManager = ProfileManager( + processor: processor, + repository: mainProfileRepository, + backupRepository: dependencies.backupProfileRepository( + model: cdRemoteModel, + observingResults: false + ), + mirrorsRemoteRepository: dependencies.mirrorsRemoteRepository + ) let tunnel = ExtendedTunnel( tunnel: Tunnel(strategy: tunnelStrategy), @@ -119,14 +119,7 @@ extension AppContext { ) let providerManager: ProviderManager = { - let store = CoreDataPersistentStore( - logger: dependencies.coreDataLogger(), - containerName: Constants.shared.containers.local, - model: cdLocalModel, - cloudKitIdentifier: nil, - author: nil - ) - let repository = AppData.cdProviderRepositoryV3(context: store.backgroundContext()) + let repository = AppData.cdProviderRepositoryV3(context: localStore.backgroundContext()) return ProviderManager(repository: repository) }() @@ -155,29 +148,61 @@ extension AppContext { return MigrationManager(profileStrategy: profileStrategy, simulation: migrationSimulation) }() - let preferencesManager: PreferencesManager = { - let preferencesStore = CoreDataPersistentStore( - logger: dependencies.coreDataLogger(), - containerName: Constants.shared.containers.remote, - model: cdRemoteModel, - cloudKitIdentifier: BundleConfiguration.mainString(for: .cloudKitId), - author: nil - ) - return PreferencesManager( - modulesFactory: { + let preferencesManager = PreferencesManager() + + // MARK: Eligibility + + let onEligibleFeaturesBlock: (Set) async -> Void = { @MainActor features in + let isEligibleForSharing = features.contains(.sharing) + let isRemoteImportingEnabled = isEligibleForSharing && isCloudKitEnabled + + // toggle CloudKit sync based on .sharing eligibility + let remoteStore = newRemoteStore(isRemoteImportingEnabled) + + // @Published + profileManager.isRemoteImportingEnabled = isRemoteImportingEnabled + + do { + pp_log(.app, .info, "\tRefresh remote sync (eligible=\(isEligibleForSharing), CloudKit=\(isCloudKitEnabled))...") + + pp_log(.App.profiles, .info, "\tRefresh remote profiles repository (sync=\(isRemoteImportingEnabled))...") + try await profileManager.observeRemote(repository: { + AppData.cdProfileRepositoryV3( + registry: dependencies.registry, + coder: dependencies.profileCoder(), + context: remoteStore.context, + observingResults: true, + onResultError: { + pp_log(.App.profiles, .error, "Unable to decode remote profile: \($0)") + return .ignore + } + ) + }()) + + pp_log(.app, .info, "\tRefresh modules preferences repository...") + preferencesManager.modulesRepositoryFactory = { try AppData.cdModulePreferencesRepositoryV3( - context: preferencesStore.context, + context: remoteStore.context, moduleId: $0 ) - }, - providersFactory: { + } + + pp_log(.app, .info, "\tRefresh providers preferences repository...") + preferencesManager.providersRepositoryFactory = { try AppData.cdProviderPreferencesRepositoryV3( - context: preferencesStore.context, + context: remoteStore.context, providerId: $0 ) } - ) - }() + } catch { + pp_log(.App.profiles, .error, "\tUnable to re-observe remote profiles: \(error)") + } + + pp_log(.App.profiles, .info, "\tReload profiles required features...") + profileManager.reloadRequiredFeatures() + } + + // MARK: Build return AppContext( iapManager: iapManager, @@ -187,11 +212,25 @@ extension AppContext { preferencesManager: preferencesManager, registry: dependencies.registry, tunnel: tunnel, - tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt + tunnelReceiptURL: tunnelReceiptURL, + onEligibleFeaturesBlock: onEligibleFeaturesBlock ) }() } +private extension AppContext { + static var isCloudKitEnabled: Bool { +#if os(tvOS) + true +#else + if AppCommandLine.contains(.uiTesting) { + return true + } + return FileManager.default.ubiquityIdentityToken != nil +#endif + } +} + // MARK: - Dependencies private extension Dependencies { @@ -237,15 +276,7 @@ private extension Dependencies { #endif } - func backupProfileRepository(model: NSManagedObjectModel) -> ProfileRepository? { -#if targetEnvironment(simulator) - nil -#else - coreDataProfileRepository(model: model, observingResults: false) -#endif - } - - func coreDataProfileRepository(model: NSManagedObjectModel, observingResults: Bool) -> ProfileRepository { + func backupProfileRepository(model: NSManagedObjectModel, observingResults: Bool) -> ProfileRepository { let store = CoreDataPersistentStore( logger: coreDataLogger(), containerName: Constants.shared.containers.backup, @@ -255,7 +286,7 @@ private extension Dependencies { ) return AppData.cdProfileRepositoryV3( registry: registry, - coder: CodableProfileCoder(), + coder: profileCoder(), context: store.context, observingResults: observingResults, onResultError: { diff --git a/Passepartout/App/Context/ProfileManager+Testing.swift b/Passepartout/App/Context/ProfileManager+Testing.swift index e083673c0..4ad82aaf7 100644 --- a/Passepartout/App/Context/ProfileManager+Testing.swift +++ b/Passepartout/App/Context/ProfileManager+Testing.swift @@ -31,14 +31,12 @@ extension ProfileManager { public static func forUITesting(withRegistry registry: Registry, processor: ProfileProcessor) -> ProfileManager { let repository = InMemoryProfileRepository() let remoteRepository = InMemoryProfileRepository() - let manager = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in - remoteRepository - }, processor: processor) + let manager = ProfileManager(processor: processor, repository: repository) Task { do { try await manager.observeLocal() - try await manager.observeRemote(true) + try await manager.observeRemote(repository: remoteRepository) for parameters in mockParameters { var builder = Profile.Builder() diff --git a/Passepartout/Shared/Dependencies+PassepartoutKit.swift b/Passepartout/Shared/Dependencies+PassepartoutKit.swift index 77afc1925..7b9b70a8e 100644 --- a/Passepartout/Shared/Dependencies+PassepartoutKit.swift +++ b/Passepartout/Shared/Dependencies+PassepartoutKit.swift @@ -33,11 +33,15 @@ extension Dependencies { Self.sharedRegistry } + func profileCoder() -> ProfileCoder { + CodableProfileCoder() + } + func neProtocolCoder() -> NEProtocolCoder { KeychainNEProtocolCoder( tunnelBundleIdentifier: BundleConfiguration.mainString(for: .tunnelId), registry: registry, - coder: CodableProfileCoder(), + coder: profileCoder(), keychain: AppleKeychain(group: BundleConfiguration.mainString(for: .keychainGroupId)) ) }