Fix regressions with CloudKit synchronization (#1029)

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. Even when features are eventually empty,
a value is always emitted after IAPManager.reloadReceipt()

Lastly, enable Core Data lightweight migration.

Regressions from #1017
This commit is contained in:
Davide 2024-12-20 10:05:07 +01:00 committed by GitHub
parent 26e97625fa
commit f8e623e1fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 191 additions and 224 deletions

View File

@ -212,7 +212,7 @@ private extension ProfileRowView {
} }
.task { .task {
do { do {
try await profileManager.observeRemote(true) try await profileManager.observeRemote(repository: InMemoryProfileRepository())
try await profileManager.save(profile, isLocal: true, remotelyShared: true) try await profileManager.save(profile, isLocal: true, remotelyShared: true)
} catch { } catch {
fatalError(error.localizedDescription) fatalError(error.localizedDescription)

View File

@ -29,18 +29,15 @@ import PassepartoutKit
@MainActor @MainActor
public final class PreferencesManager: ObservableObject { 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( public init() {
modulesFactory: ((UUID) throws -> ModulePreferencesRepository)? = nil, modulesRepositoryFactory = { _ in
providersFactory: ((ProviderID) throws -> ProviderPreferencesRepository)? = nil
) {
self.modulesFactory = modulesFactory ?? { _ in
DummyModulePreferencesRepository() DummyModulePreferencesRepository()
} }
self.providersFactory = providersFactory ?? { _ in providersRepositoryFactory = { _ in
DummyProviderPreferencesRepository() DummyProviderPreferencesRepository()
} }
} }
@ -48,11 +45,11 @@ public final class PreferencesManager: ObservableObject {
extension PreferencesManager { extension PreferencesManager {
public func preferencesRepository(forModuleWithId moduleId: UUID) throws -> ModulePreferencesRepository { public func preferencesRepository(forModuleWithId moduleId: UUID) throws -> ModulePreferencesRepository {
try modulesFactory(moduleId) try modulesRepositoryFactory(moduleId)
} }
public func preferencesRepository(forProviderWithId providerId: ProviderID) throws -> ProviderPreferencesRepository { public func preferencesRepository(forProviderWithId providerId: ProviderID) throws -> ProviderPreferencesRepository {
try providersFactory(providerId) try providersRepositoryFactory(providerId)
} }
} }

View File

@ -59,8 +59,6 @@ public final class ProfileManager: ObservableObject {
private let backupRepository: ProfileRepository? private let backupRepository: ProfileRepository?
private let remoteRepositoryBlock: ((Bool) -> ProfileRepository)?
private var remoteRepository: ProfileRepository? private var remoteRepository: ProfileRepository?
private let mirrorsRemoteRepository: Bool private let mirrorsRemoteRepository: Bool
@ -94,7 +92,7 @@ public final class ProfileManager: ObservableObject {
private var requiredFeatures: [Profile.ID: Set<AppFeature>] private var requiredFeatures: [Profile.ID: Set<AppFeature>]
@Published @Published
public private(set) var isRemoteImportingEnabled: Bool public var isRemoteImportingEnabled = false
private var waitingObservers: Set<Observer> { private var waitingObservers: Set<Observer> {
didSet { didSet {
@ -120,34 +118,25 @@ public final class ProfileManager: ObservableObject {
// for testing/previews // for testing/previews
public convenience init(profiles: [Profile]) { public convenience init(profiles: [Profile]) {
self.init( self.init(repository: InMemoryProfileRepository(profiles: profiles))
repository: InMemoryProfileRepository(profiles: profiles),
remoteRepositoryBlock: { _ in
InMemoryProfileRepository()
}
)
} }
public init( public init(
processor: ProfileProcessor? = nil,
repository: ProfileRepository, repository: ProfileRepository,
backupRepository: ProfileRepository? = nil, backupRepository: ProfileRepository? = nil,
remoteRepositoryBlock: ((Bool) -> ProfileRepository)?, mirrorsRemoteRepository: Bool = false
mirrorsRemoteRepository: Bool = false,
processor: ProfileProcessor? = nil
) { ) {
precondition(!mirrorsRemoteRepository || remoteRepositoryBlock != nil, "mirrorsRemoteRepository requires a non-nil remoteRepositoryBlock") self.processor = processor
self.repository = repository self.repository = repository
self.backupRepository = backupRepository self.backupRepository = backupRepository
self.remoteRepositoryBlock = remoteRepositoryBlock
self.mirrorsRemoteRepository = mirrorsRemoteRepository self.mirrorsRemoteRepository = mirrorsRemoteRepository
self.processor = processor
allProfiles = [:] allProfiles = [:]
allRemoteProfiles = [:] allRemoteProfiles = [:]
filteredProfiles = [] filteredProfiles = []
requiredFeatures = [:] requiredFeatures = [:]
isRemoteImportingEnabled = false if mirrorsRemoteRepository {
if remoteRepositoryBlock != nil {
waitingObservers = [.local, .remote] waitingObservers = [.local, .remote]
} else { } else {
waitingObservers = [.local] waitingObservers = [.local]
@ -341,24 +330,13 @@ extension ProfileManager {
} }
} }
public func observeRemote(_ isRemoteImportingEnabled: Bool) async throws { public func observeRemote(repository: ProfileRepository) async throws {
guard let remoteRepositoryBlock else {
// preconditionFailure("Missing remoteRepositoryBlock")
return
}
guard remoteRepository == nil || isRemoteImportingEnabled != self.isRemoteImportingEnabled else {
return
}
self.isRemoteImportingEnabled = isRemoteImportingEnabled
remoteSubscription = nil remoteSubscription = nil
let newRepository = remoteRepositoryBlock(isRemoteImportingEnabled) remoteRepository = repository
let initialProfiles = try await newRepository.fetchProfiles() let initialProfiles = try await repository.fetchProfiles()
reloadRemoteProfiles(initialProfiles) reloadRemoteProfiles(initialProfiles)
remoteRepository = newRepository
remoteSubscription = remoteRepository? remoteSubscription = repository
.profilesPublisher .profilesPublisher
.dropFirst() .dropFirst()
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
@ -422,6 +400,7 @@ private extension ProfileManager {
if waitingObservers.contains(.remote) { if waitingObservers.contains(.remote) {
waitingObservers.remove(.remote) waitingObservers.remove(.remote)
} }
Task { [weak self] in Task { [weak self] in
self?.didChange.send(.startRemoteImport) self?.didChange.send(.startRemoteImport)
await self?.importRemoteProfiles(result) await self?.importRemoteProfiles(result)

View File

@ -90,6 +90,10 @@ public final class CoreDataPersistentStore: Sendable {
// container was formerly created with CloudKit option // container was formerly created with CloudKit option
desc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) 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) // report remote notifications (do this BEFORE loadPersistentStores)
// //
// https://stackoverflow.com/a/69507329/784615 // https://stackoverflow.com/a/69507329/784615

View File

@ -180,20 +180,19 @@ private extension CoreDataRepository {
request.predicate = predicate request.predicate = predicate
beforeFetch?(request) beforeFetch?(request)
let newController = try await context.perform { let newController = NSFetchedResultsController(
let newController = NSFetchedResultsController( fetchRequest: request,
fetchRequest: request, managedObjectContext: self.context,
managedObjectContext: self.context, sectionNameKeyPath: nil,
sectionNameKeyPath: nil, cacheName: nil
cacheName: nil )
) newController.delegate = self
newController.delegate = self
try newController.performFetch()
return newController
}
resultsController = newController resultsController = newController
return await sendResults(from: newController)
return try await context.perform {
try newController.performFetch()
return self.unsafeSendResults(from: newController)
}
} }
@discardableResult @discardableResult

View File

@ -48,6 +48,8 @@ public final class AppContext: ObservableObject, Sendable {
private let tunnelReceiptURL: URL? private let tunnelReceiptURL: URL?
private let onEligibleFeaturesBlock: ((Set<AppFeature>) async -> Void)?
private var launchTask: Task<Void, Error>? private var launchTask: Task<Void, Error>?
private var pendingTask: Task<Void, Never>? private var pendingTask: Task<Void, Never>?
@ -62,7 +64,8 @@ public final class AppContext: ObservableObject, Sendable {
preferencesManager: PreferencesManager, preferencesManager: PreferencesManager,
registry: Registry, registry: Registry,
tunnel: ExtendedTunnel, tunnel: ExtendedTunnel,
tunnelReceiptURL: URL? tunnelReceiptURL: URL?,
onEligibleFeaturesBlock: ((Set<AppFeature>) async -> Void)? = nil
) { ) {
self.iapManager = iapManager self.iapManager = iapManager
self.migrationManager = migrationManager self.migrationManager = migrationManager
@ -72,6 +75,7 @@ public final class AppContext: ObservableObject, Sendable {
self.registry = registry self.registry = registry
self.tunnel = tunnel self.tunnel = tunnel
self.tunnelReceiptURL = tunnelReceiptURL self.tunnelReceiptURL = tunnelReceiptURL
self.onEligibleFeaturesBlock = onEligibleFeaturesBlock
subscriptions = [] subscriptions = []
} }
} }
@ -99,22 +103,16 @@ private extension AppContext {
pp_log(.App.profiles, .info, "\tObserve in-app events...") pp_log(.App.profiles, .info, "\tObserve in-app events...")
iapManager.observeObjects() iapManager.observeObjects()
// load in background, see comment right below // defer load receipt
Task { Task {
await iapManager.reloadReceipt() 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...") pp_log(.App.profiles, .info, "\tObserve eligible features...")
iapManager iapManager
.$eligibleFeatures .$eligibleFeatures
// .removeDuplicates() .dropFirst()
.removeDuplicates()
.sink { [weak self] eligible in .sink { [weak self] eligible in
Task { Task {
try await self?.onEligibleFeatures(eligible) try await self?.onEligibleFeatures(eligible)
@ -184,19 +182,7 @@ private extension AppContext {
pp_log(.app, .notice, "Application did update eligible features") pp_log(.app, .notice, "Application did update eligible features")
pendingTask = Task { pendingTask = Task {
await onEligibleFeaturesBlock?(features)
// 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 pendingTask?.value await pendingTask?.value
pendingTask = nil pendingTask = nil
@ -262,18 +248,3 @@ private extension AppContext {
return didLaunch 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
}
}

View File

@ -50,7 +50,7 @@ extension ProfileManagerTests {
func test_givenRepository_whenNotReady_thenHasNoProfiles() { func test_givenRepository_whenNotReady_thenHasNoProfiles() {
let repository = InMemoryProfileRepository(profiles: []) let repository = InMemoryProfileRepository(profiles: [])
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil) let sut = ProfileManager(repository: repository)
XCTAssertFalse(sut.isReady) XCTAssertFalse(sut.isReady)
XCTAssertFalse(sut.hasProfiles) XCTAssertFalse(sut.hasProfiles)
XCTAssertTrue(sut.previews.isEmpty) XCTAssertTrue(sut.previews.isEmpty)
@ -59,7 +59,7 @@ extension ProfileManagerTests {
func test_givenRepository_whenReady_thenHasProfiles() async throws { func test_givenRepository_whenReady_thenHasProfiles() async throws {
let profile = newProfile() let profile = newProfile()
let repository = InMemoryProfileRepository(profiles: [profile]) let repository = InMemoryProfileRepository(profiles: [profile])
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil) let sut = ProfileManager(repository: repository)
try await waitForReady(sut) try await waitForReady(sut)
XCTAssertTrue(sut.isReady) XCTAssertTrue(sut.isReady)
@ -72,7 +72,7 @@ extension ProfileManagerTests {
let profile1 = newProfile("foo") let profile1 = newProfile("foo")
let profile2 = newProfile("bar") let profile2 = newProfile("bar")
let repository = InMemoryProfileRepository(profiles: [profile1, profile2]) let repository = InMemoryProfileRepository(profiles: [profile1, profile2])
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil) let sut = ProfileManager(repository: repository)
try await waitForReady(sut) try await waitForReady(sut)
XCTAssertTrue(sut.isReady) XCTAssertTrue(sut.isReady)
@ -93,7 +93,7 @@ extension ProfileManagerTests {
let repository = InMemoryProfileRepository(profiles: [profile]) let repository = InMemoryProfileRepository(profiles: [profile])
let processor = MockProfileProcessor() let processor = MockProfileProcessor()
processor.requiredFeatures = [.appleTV, .onDemand] processor.requiredFeatures = [.appleTV, .onDemand]
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil, processor: processor) let sut = ProfileManager(processor: processor, repository: repository)
try await waitForReady(sut) try await waitForReady(sut)
XCTAssertTrue(sut.isReady) XCTAssertTrue(sut.isReady)
@ -115,7 +115,7 @@ extension ProfileManagerTests {
processor.isIncludedBlock = { processor.isIncludedBlock = {
$0.name == "local2" $0.name == "local2"
} }
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil, processor: processor) let sut = ProfileManager(processor: processor, repository: repository)
try await waitForReady(sut) try await waitForReady(sut)
XCTAssertTrue(sut.isReady) XCTAssertTrue(sut.isReady)
@ -129,7 +129,7 @@ extension ProfileManagerTests {
let repository = InMemoryProfileRepository(profiles: [profile]) let repository = InMemoryProfileRepository(profiles: [profile])
let processor = MockProfileProcessor() let processor = MockProfileProcessor()
processor.requiredFeatures = [.appleTV, .onDemand] processor.requiredFeatures = [.appleTV, .onDemand]
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil, processor: processor) let sut = ProfileManager(processor: processor, repository: repository)
try await waitForReady(sut) try await waitForReady(sut)
XCTAssertTrue(sut.isReady) XCTAssertTrue(sut.isReady)
@ -155,7 +155,7 @@ extension ProfileManagerTests {
extension ProfileManagerTests { extension ProfileManagerTests {
func test_givenRepository_whenSave_thenIsSaved() async throws { func test_givenRepository_whenSave_thenIsSaved() async throws {
let repository = InMemoryProfileRepository(profiles: []) let repository = InMemoryProfileRepository(profiles: [])
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil) let sut = ProfileManager(repository: repository)
try await waitForReady(sut) try await waitForReady(sut)
XCTAssertTrue(sut.isReady) XCTAssertTrue(sut.isReady)
@ -172,7 +172,7 @@ extension ProfileManagerTests {
func test_givenRepository_whenSaveExisting_thenIsReplaced() async throws { func test_givenRepository_whenSaveExisting_thenIsReplaced() async throws {
let profile = newProfile("oldName") let profile = newProfile("oldName")
let repository = InMemoryProfileRepository(profiles: [profile]) let repository = InMemoryProfileRepository(profiles: [profile])
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil) let sut = ProfileManager(repository: repository)
try await waitForReady(sut) try await waitForReady(sut)
XCTAssertTrue(sut.isReady) XCTAssertTrue(sut.isReady)
@ -191,7 +191,7 @@ extension ProfileManagerTests {
func test_givenRepositoryAndProcessor_whenSave_thenProcessorIsNotInvoked() async throws { func test_givenRepositoryAndProcessor_whenSave_thenProcessorIsNotInvoked() async throws {
let repository = InMemoryProfileRepository(profiles: []) let repository = InMemoryProfileRepository(profiles: [])
let processor = MockProfileProcessor() let processor = MockProfileProcessor()
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil, processor: processor) let sut = ProfileManager(processor: processor, repository: repository)
try await waitForReady(sut) try await waitForReady(sut)
XCTAssertTrue(sut.isReady) XCTAssertTrue(sut.isReady)
@ -207,7 +207,7 @@ extension ProfileManagerTests {
func test_givenRepositoryAndProcessor_whenSaveLocal_thenProcessorIsInvoked() async throws { func test_givenRepositoryAndProcessor_whenSaveLocal_thenProcessorIsInvoked() async throws {
let repository = InMemoryProfileRepository(profiles: []) let repository = InMemoryProfileRepository(profiles: [])
let processor = MockProfileProcessor() let processor = MockProfileProcessor()
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil, processor: processor) let sut = ProfileManager(processor: processor, repository: repository)
try await waitForReady(sut) try await waitForReady(sut)
XCTAssertTrue(sut.isReady) XCTAssertTrue(sut.isReady)
@ -221,7 +221,7 @@ extension ProfileManagerTests {
func test_givenRepository_whenSave_thenIsStoredToBackUpRepository() async throws { func test_givenRepository_whenSave_thenIsStoredToBackUpRepository() async throws {
let repository = InMemoryProfileRepository(profiles: []) let repository = InMemoryProfileRepository(profiles: [])
let backupRepository = 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) try await waitForReady(sut)
XCTAssertTrue(sut.isReady) XCTAssertTrue(sut.isReady)
@ -247,7 +247,7 @@ extension ProfileManagerTests {
func test_givenRepository_whenRemove_thenIsRemoved() async throws { func test_givenRepository_whenRemove_thenIsRemoved() async throws {
let profile = newProfile() let profile = newProfile()
let repository = InMemoryProfileRepository(profiles: [profile]) let repository = InMemoryProfileRepository(profiles: [profile])
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil) let sut = ProfileManager(repository: repository)
try await waitForReady(sut) try await waitForReady(sut)
XCTAssertTrue(sut.isReady) XCTAssertTrue(sut.isReady)
@ -267,11 +267,9 @@ extension ProfileManagerTests {
let profile = newProfile() let profile = newProfile()
let repository = InMemoryProfileRepository() let repository = InMemoryProfileRepository()
let remoteRepository = InMemoryProfileRepository() let remoteRepository = InMemoryProfileRepository()
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in let sut = ProfileManager(repository: repository)
remoteRepository
})
try await waitForReady(sut) try await waitForReady(sut, remoteRepository: remoteRepository)
let exp = expectation(description: "Remote") let exp = expectation(description: "Remote")
remoteRepository remoteRepository
@ -291,15 +289,13 @@ extension ProfileManagerTests {
XCTAssertTrue(sut.isRemotelyShared(profileWithId: profile.id)) XCTAssertTrue(sut.isRemotelyShared(profileWithId: profile.id))
} }
func test_givenRemoteRepository_whenSaveNotRemotelyShared_thenIsRemoveFromRemoteRepository() async throws { func test_givenRemoteRepository_whenSaveNotRemotelyShared_thenIsRemovedFromRemoteRepository() async throws {
let profile = newProfile() let profile = newProfile()
let repository = InMemoryProfileRepository(profiles: [profile]) let repository = InMemoryProfileRepository(profiles: [profile])
let remoteRepository = InMemoryProfileRepository(profiles: [profile]) let remoteRepository = InMemoryProfileRepository(profiles: [profile])
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in let sut = ProfileManager(repository: repository)
remoteRepository
})
try await waitForReady(sut) try await waitForReady(sut, remoteRepository: remoteRepository)
let exp = expectation(description: "Remote") let exp = expectation(description: "Remote")
remoteRepository remoteRepository
@ -325,7 +321,7 @@ extension ProfileManagerTests {
func test_givenRepository_whenNew_thenReturnsProfileWithNewName() async throws { func test_givenRepository_whenNew_thenReturnsProfileWithNewName() async throws {
let profile = newProfile("example") let profile = newProfile("example")
let repository = InMemoryProfileRepository(profiles: [profile]) let repository = InMemoryProfileRepository(profiles: [profile])
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil) let sut = ProfileManager(repository: repository)
try await waitForReady(sut) try await waitForReady(sut)
XCTAssertEqual(sut.previews.count, 1) XCTAssertEqual(sut.previews.count, 1)
@ -337,7 +333,7 @@ extension ProfileManagerTests {
func test_givenRepository_whenDuplicate_thenSavesProfileWithNewName() async throws { func test_givenRepository_whenDuplicate_thenSavesProfileWithNewName() async throws {
let profile = newProfile("example") let profile = newProfile("example")
let repository = InMemoryProfileRepository(profiles: [profile]) let repository = InMemoryProfileRepository(profiles: [profile])
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil) let sut = ProfileManager(repository: repository)
try await waitForReady(sut) try await waitForReady(sut)
@ -381,13 +377,11 @@ extension ProfileManagerTests {
let allProfiles = localProfiles + remoteProfiles let allProfiles = localProfiles + remoteProfiles
let repository = InMemoryProfileRepository(profiles: localProfiles) let repository = InMemoryProfileRepository(profiles: localProfiles)
let remoteRepository = InMemoryProfileRepository(profiles: remoteProfiles) let remoteRepository = InMemoryProfileRepository(profiles: remoteProfiles)
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in let sut = ProfileManager(repository: repository)
remoteRepository
})
try await wait(sut, "Remote import", until: .stopRemoteImport) { try await wait(sut, "Remote import", until: .stopRemoteImport) {
try await $0.observeLocal() try await $0.observeLocal()
try await $0.observeRemote(true) try await $0.observeRemote(repository: remoteRepository)
} }
XCTAssertEqual(sut.previews.count, allProfiles.count) XCTAssertEqual(sut.previews.count, allProfiles.count)
@ -417,13 +411,11 @@ extension ProfileManagerTests {
] ]
let repository = InMemoryProfileRepository(profiles: localProfiles) let repository = InMemoryProfileRepository(profiles: localProfiles)
let remoteRepository = InMemoryProfileRepository(profiles: remoteProfiles) let remoteRepository = InMemoryProfileRepository(profiles: remoteProfiles)
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in let sut = ProfileManager(repository: repository)
remoteRepository
})
try await wait(sut, "Remote import", until: .stopRemoteImport) { try await wait(sut, "Remote import", until: .stopRemoteImport) {
try await $0.observeLocal() try await $0.observeLocal()
try await $0.observeRemote(true) try await $0.observeRemote(repository: remoteRepository)
} }
XCTAssertEqual(sut.previews.count, 4) // unique IDs XCTAssertEqual(sut.previews.count, 4) // unique IDs
@ -464,13 +456,11 @@ extension ProfileManagerTests {
processor.isIncludedBlock = { processor.isIncludedBlock = {
!$0.name.hasPrefix("remote") !$0.name.hasPrefix("remote")
} }
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in let sut = ProfileManager(processor: processor, repository: repository)
remoteRepository
}, processor: processor)
try await wait(sut, "Remote import", until: .stopRemoteImport) { try await wait(sut, "Remote import", until: .stopRemoteImport) {
try await $0.observeLocal() try await $0.observeLocal()
try await $0.observeRemote(true) try await $0.observeRemote(repository: remoteRepository)
} }
XCTAssertEqual(processor.isIncludedCount, allProfiles.count) XCTAssertEqual(processor.isIncludedCount, allProfiles.count)
@ -499,13 +489,11 @@ extension ProfileManagerTests {
let repository = InMemoryProfileRepository(profiles: localProfiles) let repository = InMemoryProfileRepository(profiles: localProfiles)
let remoteRepository = InMemoryProfileRepository(profiles: remoteProfiles) let remoteRepository = InMemoryProfileRepository(profiles: remoteProfiles)
let processor = MockProfileProcessor() let processor = MockProfileProcessor()
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in let sut = ProfileManager(processor: processor, repository: repository)
remoteRepository
}, processor: processor)
try await wait(sut, "Remote import", until: .stopRemoteImport) { try await wait(sut, "Remote import", until: .stopRemoteImport) {
try await $0.observeLocal() try await $0.observeLocal()
try await $0.observeRemote(true) try await $0.observeRemote(repository: remoteRepository)
} }
try sut.previews.forEach { try sut.previews.forEach {
@ -531,13 +519,11 @@ extension ProfileManagerTests {
] ]
let repository = InMemoryProfileRepository(profiles: localProfiles) let repository = InMemoryProfileRepository(profiles: localProfiles)
let remoteRepository = InMemoryProfileRepository() let remoteRepository = InMemoryProfileRepository()
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in let sut = ProfileManager(repository: repository)
remoteRepository
})
try await wait(sut, "Remote import", until: .stopRemoteImport) { try await wait(sut, "Remote import", until: .stopRemoteImport) {
try await $0.observeLocal() try await $0.observeLocal()
try await $0.observeRemote(true) try await $0.observeRemote(repository: remoteRepository)
} }
XCTAssertEqual(sut.previews.count, localProfiles.count) XCTAssertEqual(sut.previews.count, localProfiles.count)
@ -589,13 +575,11 @@ extension ProfileManagerTests {
let remoteProfiles = [profile] let remoteProfiles = [profile]
let repository = InMemoryProfileRepository(profiles: localProfiles) let repository = InMemoryProfileRepository(profiles: localProfiles)
let remoteRepository = InMemoryProfileRepository(profiles: remoteProfiles) let remoteRepository = InMemoryProfileRepository(profiles: remoteProfiles)
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in let sut = ProfileManager(repository: repository)
remoteRepository
})
try await wait(sut, "Remote import", until: .stopRemoteImport) { try await wait(sut, "Remote import", until: .stopRemoteImport) {
try await $0.observeLocal() try await $0.observeLocal()
try await $0.observeRemote(true) try await $0.observeRemote(repository: remoteRepository)
} }
XCTAssertEqual(sut.previews.count, 1) XCTAssertEqual(sut.previews.count, 1)
@ -611,13 +595,11 @@ extension ProfileManagerTests {
let localProfiles = [profile] let localProfiles = [profile]
let repository = InMemoryProfileRepository(profiles: localProfiles) let repository = InMemoryProfileRepository(profiles: localProfiles)
let remoteRepository = InMemoryProfileRepository(profiles: localProfiles) let remoteRepository = InMemoryProfileRepository(profiles: localProfiles)
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in let sut = ProfileManager(repository: repository, mirrorsRemoteRepository: true)
remoteRepository
}, mirrorsRemoteRepository: true)
try await wait(sut, "Remote import", until: .stopRemoteImport) { try await wait(sut, "Remote import", until: .stopRemoteImport) {
try await $0.observeLocal() try await $0.observeLocal()
try await $0.observeRemote(true) try await $0.observeRemote(repository: remoteRepository)
} }
XCTAssertEqual(sut.previews.count, 1) 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 wait(sut, "Ready", until: .ready) {
try await $0.observeLocal() try await $0.observeLocal()
try await $0.observeRemote(importingRemote) if let remoteRepository {
try await $0.observeRemote(repository: remoteRepository)
}
} }
} }

View File

@ -40,18 +40,39 @@ extension AppContext {
static let shared: AppContext = { static let shared: AppContext = {
let dependencies: Dependencies = .shared 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: [ guard let cdRemoteModel = NSManagedObjectModel.mergedModel(from: [
AppData.profilesBundle, AppData.profilesBundle,
AppData.preferencesBundle AppData.preferencesBundle
]) else { ]) else {
fatalError("Unable to load remote model") fatalError("Unable to load remote model")
} }
guard let cdLocalModel = NSManagedObjectModel.mergedModel(from: [
AppData.providersBundle let localStore = CoreDataPersistentStore(
]) else { logger: dependencies.coreDataLogger(),
fatalError("Unable to load local model") 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( let iapManager = IAPManager(
customUserLevel: dependencies.customUserLevel, customUserLevel: dependencies.customUserLevel,
inAppHelper: dependencies.simulatedAppProductHelper(), inAppHelper: dependencies.simulatedAppProductHelper(),
@ -59,13 +80,13 @@ extension AppContext {
betaChecker: dependencies.betaChecker(), betaChecker: dependencies.betaChecker(),
productsAtBuild: dependencies.productsAtBuild() productsAtBuild: dependencies.productsAtBuild()
) )
let processor = dependencies.appProcessor(with: iapManager) let processor = dependencies.appProcessor(with: iapManager)
let tunnelEnvironment = dependencies.tunnelEnvironment() let tunnelReceiptURL = BundleConfiguration.urlForBetaReceipt
let tunnelEnvironment = dependencies.tunnelEnvironment()
#if targetEnvironment(simulator) #if targetEnvironment(simulator)
let tunnelStrategy = FakeTunnelStrategy(environment: tunnelEnvironment, dataCountInterval: 1000) let tunnelStrategy = FakeTunnelStrategy(environment: tunnelEnvironment, dataCountInterval: 1000)
let mainProfileRepository = dependencies.coreDataProfileRepository( let mainProfileRepository = dependencies.backupProfileRepository(
model: cdRemoteModel, model: cdRemoteModel,
observingResults: true observingResults: true
) )
@ -80,36 +101,15 @@ extension AppContext {
} }
#endif #endif
let profileManager: ProfileManager = { let profileManager = ProfileManager(
let remoteRepositoryBlock: (Bool) -> ProfileRepository = { processor: processor,
let remoteStore = CoreDataPersistentStore( repository: mainProfileRepository,
logger: dependencies.coreDataLogger(), backupRepository: dependencies.backupProfileRepository(
containerName: Constants.shared.containers.remote, model: cdRemoteModel,
model: cdRemoteModel, observingResults: false
cloudKitIdentifier: $0 ? BundleConfiguration.mainString(for: .cloudKitId) : nil, ),
author: nil mirrorsRemoteRepository: dependencies.mirrorsRemoteRepository
) )
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 tunnel = ExtendedTunnel( let tunnel = ExtendedTunnel(
tunnel: Tunnel(strategy: tunnelStrategy), tunnel: Tunnel(strategy: tunnelStrategy),
@ -119,14 +119,7 @@ extension AppContext {
) )
let providerManager: ProviderManager = { let providerManager: ProviderManager = {
let store = CoreDataPersistentStore( let repository = AppData.cdProviderRepositoryV3(context: localStore.backgroundContext())
logger: dependencies.coreDataLogger(),
containerName: Constants.shared.containers.local,
model: cdLocalModel,
cloudKitIdentifier: nil,
author: nil
)
let repository = AppData.cdProviderRepositoryV3(context: store.backgroundContext())
return ProviderManager(repository: repository) return ProviderManager(repository: repository)
}() }()
@ -155,29 +148,61 @@ extension AppContext {
return MigrationManager(profileStrategy: profileStrategy, simulation: migrationSimulation) return MigrationManager(profileStrategy: profileStrategy, simulation: migrationSimulation)
}() }()
let preferencesManager: PreferencesManager = { let preferencesManager = PreferencesManager()
let preferencesStore = CoreDataPersistentStore(
logger: dependencies.coreDataLogger(), // MARK: Eligibility
containerName: Constants.shared.containers.remote,
model: cdRemoteModel, let onEligibleFeaturesBlock: (Set<AppFeature>) async -> Void = { @MainActor features in
cloudKitIdentifier: BundleConfiguration.mainString(for: .cloudKitId), let isEligibleForSharing = features.contains(.sharing)
author: nil let isRemoteImportingEnabled = isEligibleForSharing && isCloudKitEnabled
)
return PreferencesManager( // toggle CloudKit sync based on .sharing eligibility
modulesFactory: { 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( try AppData.cdModulePreferencesRepositoryV3(
context: preferencesStore.context, context: remoteStore.context,
moduleId: $0 moduleId: $0
) )
}, }
providersFactory: {
pp_log(.app, .info, "\tRefresh providers preferences repository...")
preferencesManager.providersRepositoryFactory = {
try AppData.cdProviderPreferencesRepositoryV3( try AppData.cdProviderPreferencesRepositoryV3(
context: preferencesStore.context, context: remoteStore.context,
providerId: $0 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( return AppContext(
iapManager: iapManager, iapManager: iapManager,
@ -187,11 +212,25 @@ extension AppContext {
preferencesManager: preferencesManager, preferencesManager: preferencesManager,
registry: dependencies.registry, registry: dependencies.registry,
tunnel: tunnel, 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 // MARK: - Dependencies
private extension Dependencies { private extension Dependencies {
@ -237,15 +276,7 @@ private extension Dependencies {
#endif #endif
} }
func backupProfileRepository(model: NSManagedObjectModel) -> ProfileRepository? { func backupProfileRepository(model: NSManagedObjectModel, observingResults: Bool) -> ProfileRepository {
#if targetEnvironment(simulator)
nil
#else
coreDataProfileRepository(model: model, observingResults: false)
#endif
}
func coreDataProfileRepository(model: NSManagedObjectModel, observingResults: Bool) -> ProfileRepository {
let store = CoreDataPersistentStore( let store = CoreDataPersistentStore(
logger: coreDataLogger(), logger: coreDataLogger(),
containerName: Constants.shared.containers.backup, containerName: Constants.shared.containers.backup,
@ -255,7 +286,7 @@ private extension Dependencies {
) )
return AppData.cdProfileRepositoryV3( return AppData.cdProfileRepositoryV3(
registry: registry, registry: registry,
coder: CodableProfileCoder(), coder: profileCoder(),
context: store.context, context: store.context,
observingResults: observingResults, observingResults: observingResults,
onResultError: { onResultError: {

View File

@ -31,14 +31,12 @@ extension ProfileManager {
public static func forUITesting(withRegistry registry: Registry, processor: ProfileProcessor) -> ProfileManager { public static func forUITesting(withRegistry registry: Registry, processor: ProfileProcessor) -> ProfileManager {
let repository = InMemoryProfileRepository() let repository = InMemoryProfileRepository()
let remoteRepository = InMemoryProfileRepository() let remoteRepository = InMemoryProfileRepository()
let manager = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in let manager = ProfileManager(processor: processor, repository: repository)
remoteRepository
}, processor: processor)
Task { Task {
do { do {
try await manager.observeLocal() try await manager.observeLocal()
try await manager.observeRemote(true) try await manager.observeRemote(repository: remoteRepository)
for parameters in mockParameters { for parameters in mockParameters {
var builder = Profile.Builder() var builder = Profile.Builder()

View File

@ -33,11 +33,15 @@ extension Dependencies {
Self.sharedRegistry Self.sharedRegistry
} }
func profileCoder() -> ProfileCoder {
CodableProfileCoder()
}
func neProtocolCoder() -> NEProtocolCoder { func neProtocolCoder() -> NEProtocolCoder {
KeychainNEProtocolCoder( KeychainNEProtocolCoder(
tunnelBundleIdentifier: BundleConfiguration.mainString(for: .tunnelId), tunnelBundleIdentifier: BundleConfiguration.mainString(for: .tunnelId),
registry: registry, registry: registry,
coder: CodableProfileCoder(), coder: profileCoder(),
keychain: AppleKeychain(group: BundleConfiguration.mainString(for: .keychainGroupId)) keychain: AppleKeychain(group: BundleConfiguration.mainString(for: .keychainGroupId))
) )
} }