From 00de32e44a401fa405c32c4fba831a530c66fd5d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 14 Nov 2026 12:45:05 -0500 Subject: [PATCH 1/2] feat(WhereCore): add data compatibility verification and scope retirement Groundwork for the app launch gate: use an independent conditional CloudKit record, an account-bound offline receipt, and a recording-only version advance. Add permanent recording retirement and drain owned service observations without erasing the location outbox. The app wiring follows in the next commit. Validation: 73 focused Core, UI, and intent regression tests passed, including 58 Core tests; 29 architecture tests and architecture lint passed. Catalog and attribution checks passed. --- Where/WhereCore/AGENTS.md | 7 + Where/WhereCore/README.md | 18 ++ .../CloudKitDataCompatibilityTransport.swift | 166 ++++++++++++ .../DataCompatibilityCoordinator.swift | 254 +++++++++++++++++ .../DataCompatibilityReceipt.swift | 97 +++++++ .../DataCompatibilityTransport.swift | 47 ++++ .../Compatibility/DataModelVersion.swift | 43 +++ .../DataResolution/DataIssueScanner.swift | 5 + .../Devices/DeviceRecordingController.swift | 24 +- .../Sources/Location/LocationIngestor.swift | 30 ++- .../Logging/DataCompatibilityLog.swift | 24 ++ .../RemoteDataChangeReconciler.swift | 5 + .../WhereCore/Sources/RegionAttribution.swift | 5 + .../Sources/Resources/Localizable.xcstrings | 44 +++ Where/WhereCore/Sources/WhereServices.swift | 14 + .../DataCompatibilityCoordinatorTests.swift | 255 ++++++++++++++++++ .../Tests/DataCompatibilityReceiptTests.swift | 69 +++++ .../Tests/DataModelVersionTests.swift | 18 ++ .../DeviceRecordingControllerTests.swift | 11 + .../Tests/LocationIngestorTests.swift | 38 +++ .../DataCompatibilityTestSupport.swift | 119 ++++++++ .../RecordingRetirementTestSupport.swift | 30 +++ 22 files changed, 1321 insertions(+), 2 deletions(-) create mode 100644 Where/WhereCore/Sources/Compatibility/CloudKitDataCompatibilityTransport.swift create mode 100644 Where/WhereCore/Sources/Compatibility/DataCompatibilityCoordinator.swift create mode 100644 Where/WhereCore/Sources/Compatibility/DataCompatibilityReceipt.swift create mode 100644 Where/WhereCore/Sources/Compatibility/DataCompatibilityTransport.swift create mode 100644 Where/WhereCore/Sources/Compatibility/DataModelVersion.swift create mode 100644 Where/WhereCore/Sources/Logging/DataCompatibilityLog.swift create mode 100644 Where/WhereCore/Tests/DataCompatibilityCoordinatorTests.swift create mode 100644 Where/WhereCore/Tests/DataCompatibilityReceiptTests.swift create mode 100644 Where/WhereCore/Tests/DataModelVersionTests.swift create mode 100644 Where/WhereCore/Tests/Support/DataCompatibilityTestSupport.swift create mode 100644 Where/WhereCore/Tests/Support/RecordingRetirementTestSupport.swift diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index b8a3e495e..7c4dd9a7d 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -21,6 +21,13 @@ internal shape. ## Shape & invariants +- Keep compatibility metadata outside SwiftData. Inject one process-owned coordinator before every production store open. + Only local recording On with Always permission can advance the shared version. Never lower a server or receipt version. + Keep the receipt atomic, account-bound, App Group-specific, and excluded from backups. + Guards: `DataCompatibilityCoordinatorTests`, `DataCompatibilityReceiptTests`, `DataModelVersionTests`. +- Retire ingestion before joining recording operations. A retired scope must never restart GPS, including after an import completes. + Preserve consent and the durable location outbox. Guards: `LocationIngestorTests`, `DeviceRecordingControllerTests`. + - Use InstallerKit for local and published build identity, feed validation, and cache policy. Keep `WherePreferences.installerUpdates` device-local. `BuildInfo` exposes an installer identity only for the Development audience. diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 80563cbc6..8307fae8a 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -16,6 +16,24 @@ feature [`Where/AGENTS.md`](../AGENTS.md). This file is the human-facing tour. ## What you get +### Data compatibility + +`DataCompatibilityCoordinator` decides whether the app can open history before SwiftData starts. +`DataModelVersion.current` describes data semantics independently of the app version. Version 1 also covers data from builds without this gate. +The private CloudKit control record lives outside SwiftData's zone. Conditional saves only advance its version. +Only a locally enabled installation with Always location permission can advance the version. A known removed installation cannot advance it. +Other devices wait for their recording device when the shared version is older. Any device can use an equal version. +A newer shared version requires an app update. + +The atomic App Group receipt preserves the highest observed version and excludes itself from backups. +Transient failures permit offline access only after the same account and version passed verification. +CloudKit must still identify that account. Missing, corrupt, unreadable, or blocked receipts do not grant offline access. +The share extension checks this receipt before opening its local store. It does not contact CloudKit. + +`WhereServices.shutdown()` permanently stops recording and drains owned observation tasks without erasing data, consent, or pending location samples. +The app discards that service scope. Recovery creates a fresh scope after verification succeeds. +This is a scope boundary: operations already in flight can finish, and existing data APIs have no new per-operation checks. + `WhereServices` is a small struct of focused collaborators — add behavior to the one it belongs to rather than to a god-object: diff --git a/Where/WhereCore/Sources/Compatibility/CloudKitDataCompatibilityTransport.swift b/Where/WhereCore/Sources/Compatibility/CloudKitDataCompatibilityTransport.swift new file mode 100644 index 000000000..bc48aa13d --- /dev/null +++ b/Where/WhereCore/Sources/Compatibility/CloudKitDataCompatibilityTransport.swift @@ -0,0 +1,166 @@ +import CloudKit +import Foundation + +/// A single conditional record in a private zone, separate from SwiftData's mirror. +public actor CloudKitDataCompatibilityTransport: DataCompatibilityTransport { + public static let subscriptionID = "where-store-compatibility" + private static let zoneID = CKRecordZone.ID(zoneName: "WhereStoreCompatibility") + private let container: CKContainer + private let database: CKDatabase + private var subscribedAccount: DataCompatibilityAccountID? + private var retryAfter: Date? + + public init(containerIdentifier: String) { + let container = CKContainer(identifier: containerIdentifier) + self.container = container + database = container.privateCloudDatabase + } + + public func accountID() async throws -> DataCompatibilityAccountID { + do { + try checkBackoff() + return try await DataCompatibilityAccountID(rawValue: container.userRecordID() + .recordName) + } catch { throw classify(error) } + } + + public func read(accountID: DataCompatibilityAccountID) async throws + -> SharedDataModelVersion? + { + do { + try checkBackoff() + try await requireAccount(accountID) + if subscribedAccount != accountID { + _ = try await database.save(CKRecordZone(zoneID: Self.zoneID)) + let subscription = CKRecordZoneSubscription( + zoneID: Self.zoneID, + subscriptionID: Self.subscriptionID, + ) + let notification = CKSubscription.NotificationInfo() + notification.shouldSendContentAvailable = true + subscription.notificationInfo = notification + _ = try await database.save(subscription) + try await requireAccount(accountID) + subscribedAccount = accountID + } + let record = try await fetch(accountID) + try await requireAccount(accountID) + return try record.map { try Self.decode($0, accountID: accountID) } + } catch { throw classify(error) } + } + + public func advance( + accountID: DataCompatibilityAccountID, + from previous: SharedDataModelVersion?, + to version: DataModelVersion, + ) async throws -> SharedDataModelVersion { + do { + try checkBackoff() + try await requireAccount(accountID) + let record: CKRecord + if let previous { + guard let fetched = try await fetch(accountID), + fetched.recordChangeTag == previous.revision, + try Self.decode(fetched, accountID: accountID).version <= version + else { throw DataCompatibilityTransportError.conflict } + record = fetched + } else { + record = CKRecord( + recordType: "WhereStoreCompatibility", + recordID: Self.recordID(accountID), + ) + } + try Task.checkCancellation() + try await requireAccount(accountID) + record["requiredVersion"] = version.rawValue + record["accountID"] = accountID.rawValue + let result = try await database.modifyRecords( + saving: [record], + deleting: [], + savePolicy: .ifServerRecordUnchanged, + atomically: true, + ) + guard let saved = result.saveResults[record.recordID] else { + throw DataCompatibilityTransportError.invalidRecord + } + try await requireAccount(accountID) + return try Self.decode(saved.get(), accountID: accountID) + } catch { throw classify(error) } + } + + public nonisolated func accountChanges() -> AsyncStream { + let (stream, continuation) = AsyncStream + .makeStream(bufferingPolicy: .bufferingNewest(1)) + let task = Task { + for await _ in NotificationCenter.default.notifications(named: .CKAccountChanged) { + guard !Task.isCancelled else { break } + continuation.yield() + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + return stream + } + + private func requireAccount(_ expected: DataCompatibilityAccountID) async throws { + guard try await accountID() == expected else { + subscribedAccount = nil + throw DataCompatibilityTransportError.accountChanged + } + } + + private func fetch(_ accountID: DataCompatibilityAccountID) async throws -> CKRecord? { + do { return try await database.record(for: Self.recordID(accountID)) } + catch let error as CKError where error.code == .unknownItem { return nil } + } + + private static func recordID(_ accountID: DataCompatibilityAccountID) -> CKRecord.ID { + // A request crossing an account switch cannot overwrite the other account's canonical head. + CKRecord.ID(recordName: "version-" + accountID.rawValue, zoneID: zoneID) + } + + static func decode( + _ record: CKRecord, + accountID: DataCompatibilityAccountID, + ) throws -> SharedDataModelVersion { + guard record["accountID"] as? String == accountID.rawValue, + let value = record["requiredVersion"] as? Int, value > 0, + let revision = record.recordChangeTag + else { throw DataCompatibilityTransportError.invalidRecord } + return SharedDataModelVersion(version: .init(rawValue: value), revision: revision) + } + + private func checkBackoff() throws { + if let retryAfter, Date() < retryAfter { + throw DataCompatibilityTransportError.unavailable(CKError(.requestRateLimited)) + } + } + + private func classify(_ error: any Error) -> any Error { + guard let cloudError = error as? CKError else { return error } + if let interval = cloudError.retryAfterSeconds { + retryAfter = Date().addingTimeInterval(interval) + } + switch cloudError.code { + case .serverRecordChanged: return DataCompatibilityTransportError.conflict + case .networkUnavailable, .networkFailure, .serviceUnavailable, .requestRateLimited, + .zoneBusy, .serverResponseLost: + return DataCompatibilityTransportError.unavailable(error) + case .partialFailure: + if let errors = cloudError.partialErrorsByItemID, + errors.values.contains(where: { ($0 as? CKError)?.code == .serverRecordChanged }) + { return DataCompatibilityTransportError.conflict } + return error + case .internalError, .badContainer, .missingEntitlement, .notAuthenticated, + .permissionFailure, .unknownItem, .invalidArguments, .resultsTruncated, + .assetFileNotFound, .assetFileModified, .incompatibleVersion, .constraintViolation, + .operationCancelled, .changeTokenExpired, .batchRequestFailed, .zoneNotFound, + .limitExceeded, .quotaExceeded, .userDeletedZone, .tooManyParticipants, + .alreadyShared, .referenceViolation, .managedAccountRestricted, + .participantMayNeedVerification, + .assetNotAvailable, .accountTemporarilyUnavailable: + return error + @unknown default: return error + } + } +} diff --git a/Where/WhereCore/Sources/Compatibility/DataCompatibilityCoordinator.swift b/Where/WhereCore/Sources/Compatibility/DataCompatibilityCoordinator.swift new file mode 100644 index 000000000..204f85282 --- /dev/null +++ b/Where/WhereCore/Sources/Compatibility/DataCompatibilityCoordinator.swift @@ -0,0 +1,254 @@ +import Foundation +import Observation + +/// One process-wide compatibility decision. Only full server verification can restore access; +/// a same-account, same-version receipt can preserve it during a transient outage. +@MainActor +@Observable +public final class DataCompatibilityCoordinator { + public enum State { + case checking + case allowed + case offline(any Error) + case blocked(DataCompatibilityFailure) + + public var allowsStore: Bool { + switch self { + case .allowed, .offline: true + case .checking, .blocked: false + } + } + } + + public enum RecordingEligibility: Sendable { + case recording(RecordingDeviceID) + case disabled + case permissionRequired + } + + private struct Cloud { + let transport: any DataCompatibilityTransport + let receipts: any DataCompatibilityReceiptStoring + } + + public private(set) var state: State + public let version: DataModelVersion + @ObservationIgnored public var onStateChange: @MainActor () -> Void = {} + @ObservationIgnored private let cloud: Cloud? + @ObservationIgnored private var verification: Task? + @ObservationIgnored private var accountObservation: Task? + @ObservationIgnored private var epoch: UInt64 = 0 + @ObservationIgnored private var knownVersion = DataModelVersion.baseline + @ObservationIgnored private var removedDeviceID: RecordingDeviceID? + private static let logger = WhereLog.root(DataCompatibilityLog.self) + + /// Local-only and in-memory worlds do not consult or publish CloudKit metadata. + public init() { + cloud = nil + version = .current + state = .allowed + } + + public init( + transport: any DataCompatibilityTransport, + receipts: any DataCompatibilityReceiptStoring, + version: DataModelVersion, + ) { + cloud = Cloud(transport: transport, receipts: receipts) + self.version = version + state = .checking + accountObservation = Task { [weak self] in + for await _ in transport.accountChanges() { + guard !Task.isCancelled else { break } + self?.invalidateAccount() + } + } + } + + isolated deinit { + accountObservation?.cancel() + verification?.cancel() + } + + public func refresh( + recording: @escaping @MainActor () async throws -> RecordingEligibility, + ) async { + guard let cloud else { return } + if let verification { + await verification.value + return + } + let epoch = epoch + let task = Task { [weak self] in + guard let self else { return } + await Self.logger.measure(.verify, budget: .seconds(10)) { + await self.verify(cloud, epoch: epoch, recording: recording) + } + } + verification = task + await task.value + if self.epoch == epoch { verification = nil } + } + + public func requireAccess() throws { + switch state { + case .allowed, .offline: return + case .checking: + throw DataCompatibilityFailure.verificationRequired( + DataCompatibilityTransportError.accountChanged, + ) + case let .blocked(failure): throw failure + } + } + + public func recordRemoval(of deviceID: RecordingDeviceID) { + removedDeviceID = deviceID + guard let cloud else { return } + do { + if let receipt = try cloud.receipts.load() { + try cloud.receipts.save(DataCompatibilityReceipt( + accountID: receipt.accountID, + version: receipt.version, + access: receipt.access, + removedDeviceID: deviceID, + )) + } + } catch { fail(error) } + } + + private func invalidateAccount() { + epoch &+= 1 + verification?.cancel() + verification = nil + if let cloud { + do { + if let receipt = try cloud.receipts.load() { + knownVersion = max(knownVersion, receipt.version) + removedDeviceID = receipt.removedDeviceID + try save(cloud, accountID: receipt.accountID, access: .blocked) + } + } catch { Self.logger.verificationFailed(error: .restricted(.errorDetails, error)) } + } + fail(DataCompatibilityTransportError.accountChanged) + } + + private func verify( + _ cloud: Cloud, + epoch: UInt64, + recording: @escaping @MainActor () async throws -> RecordingEligibility, + ) async { + var accountID: DataCompatibilityAccountID? + var prior: DataCompatibilityReceipt? + do { + prior = try cloud.receipts.load() + if let prior { + knownVersion = max(knownVersion, prior.version) + removedDeviceID = prior.removedDeviceID + } + if knownVersion > version { + set(.blocked(.updateRequired(knownVersion))) + return + } + let account = try await cloud.transport.accountID() + try checkEpoch(epoch) + accountID = account + if prior?.accountID != account { prior = nil } + // Conflicting writers are bounded: later triggers retry if contention persists. + for _ in 0 ..< 3 { + let remote = try await cloud.transport.read(accountID: account) + try checkEpoch(epoch) + let sharedVersion = remote?.version ?? .baseline + knownVersion = max(knownVersion, sharedVersion) + if knownVersion > version { + try save(cloud, accountID: account, access: .blocked) + set(.blocked(.updateRequired(knownVersion))) + return + } + let eligibility = try await recording() + try checkEpoch(epoch) + let canPublish: Bool = switch eligibility { + case let .recording(deviceID): deviceID != removedDeviceID + case .disabled, .permissionRequired: false + } + if sharedVersion < version, !canPublish { + try save(cloud, accountID: account, access: .blocked) + if case .permissionRequired = eligibility { + set(.blocked(.locationPermissionRequired)) + } else { + set(.blocked(.recordingDeviceRequired)) + } + return + } + if sharedVersion < version || (remote == nil && canPublish) { + do { + let saved = try await cloud.transport.advance( + accountID: account, + from: remote, + to: version, + ) + try checkEpoch(epoch) + knownVersion = max(knownVersion, saved.version) + // A competing newer writer can win while the save is in flight. + guard knownVersion == version else { continue } + } catch DataCompatibilityTransportError.conflict { continue } + } + try checkEpoch(epoch) + try save(cloud, accountID: account, access: .verified) + set(.allowed) + return + } + throw DataCompatibilityTransportError.conflict + } catch is CancellationError { + return + } catch { + guard self.epoch == epoch else { return } + if case DataCompatibilityTransportError.unavailable = error, + let prior, prior.accountID == accountID, prior.access == .verified, + prior.version == version, knownVersion == version + { + Self.logger.verificationFailed(error: .restricted(.errorDetails, error)) + set(.offline(error)) + } else { + // Persist withdrawal so a separate share process cannot trust an obsolete grant. + if let account = accountID ?? prior?.accountID { + do { try save(cloud, accountID: account, access: .blocked) } + catch { + Self.logger.verificationFailed(error: .restricted(.errorDetails, error)) + } + } + fail(error) + } + } + } + + private func save( + _ cloud: Cloud, + accountID: DataCompatibilityAccountID, + access: DataCompatibilityReceipt.Access, + ) throws { + try cloud.receipts.save(DataCompatibilityReceipt( + accountID: accountID, + version: knownVersion, + access: access, + removedDeviceID: removedDeviceID, + )) + } + + private func checkEpoch(_ expected: UInt64) throws { + try Task.checkCancellation() + guard epoch == expected else { throw CancellationError() } + } + + private func fail(_ error: any Error) { + Self.logger.verificationFailed(error: .restricted(.errorDetails, error)) + set(.blocked(.verificationRequired(error))) + } + + private func set(_ state: State) { + self.state = state + if case let .blocked(failure) = state { + Self.logger.blocked(reason: .restricted(.technicalState, String(describing: failure))) + } + onStateChange() + } +} diff --git a/Where/WhereCore/Sources/Compatibility/DataCompatibilityReceipt.swift b/Where/WhereCore/Sources/Compatibility/DataCompatibilityReceipt.swift new file mode 100644 index 000000000..068bdf45c --- /dev/null +++ b/Where/WhereCore/Sources/Compatibility/DataCompatibilityReceipt.swift @@ -0,0 +1,97 @@ +import Foundation + +/// Device-local evidence shared with the share extension. It never belongs in a backup. +/// The version is a monotonic floor even when access has been withdrawn. +public struct DataCompatibilityReceipt: Codable, Sendable { + public enum Access: String, Codable, Sendable { case verified, blocked } + + public let accountID: DataCompatibilityAccountID + public let version: DataModelVersion + public let access: Access + public let removedDeviceID: RecordingDeviceID? + + public init( + accountID: DataCompatibilityAccountID, + version: DataModelVersion, + access: Access, + removedDeviceID: RecordingDeviceID?, + ) { + self.accountID = accountID + self.version = version + self.access = access + self.removedDeviceID = removedDeviceID + } + + public func requireAccess(version: DataModelVersion) throws { + guard self.version == version, access == .verified else { + throw DataCompatibilityFailure.recordingDeviceRequired + } + } +} + +@MainActor +public protocol DataCompatibilityReceiptStoring { + func load() throws -> DataCompatibilityReceipt? + func save(_ receipt: DataCompatibilityReceipt) throws +} + +/// Resolves the host-injected group lazily so entitlement/I/O failures reach the gate. +public struct AppGroupDataCompatibilityReceipt: DataCompatibilityReceiptStoring { + private let appGroupIdentifier: String + public init(appGroupIdentifier: String) { + self.appGroupIdentifier = appGroupIdentifier + } + + public func load() throws -> DataCompatibilityReceipt? { + try FileDataCompatibilityReceipt.shared(appGroupIdentifier: appGroupIdentifier).load() + } + + public func save(_ receipt: DataCompatibilityReceipt) throws { + try FileDataCompatibilityReceipt.shared(appGroupIdentifier: appGroupIdentifier) + .save(receipt) + } +} + +/// Atomic App Group sidecar. Only the app writes; extensions read before opening history. +public struct FileDataCompatibilityReceipt: DataCompatibilityReceiptStoring, Sendable { + private let directory: URL + + public init(directory: URL) { + self.directory = directory + } + + public static func shared(appGroupIdentifier: String) throws -> Self { + guard let group = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier, + ) else { throw WidgetSnapshotStore.AppGroupUnavailableError() } + return Self(directory: group.appending( + path: "DataCompatibility", + directoryHint: .isDirectory, + )) + } + + public func load() throws -> DataCompatibilityReceipt? { + do { + let data = try Data(contentsOf: directory.appending(path: "receipt.json")) + let receipt = try JSONDecoder().decode(DataCompatibilityReceipt.self, from: data) + guard receipt.version.rawValue > 0, !receipt.accountID.rawValue.isEmpty else { + throw DataCompatibilityTransportError.invalidRecord + } + return receipt + } catch let error as CocoaError where error.code == .fileReadNoSuchFile { + return nil + } + } + + public func save(_ receipt: DataCompatibilityReceipt) throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + var url = directory + var attributes = URLResourceValues() + attributes.isExcludedFromBackup = true + try url.setResourceValues(attributes) + try JSONEncoder().encode(receipt).write( + to: directory.appending(path: "receipt.json"), + options: .atomic, + ) + } +} diff --git a/Where/WhereCore/Sources/Compatibility/DataCompatibilityTransport.swift b/Where/WhereCore/Sources/Compatibility/DataCompatibilityTransport.swift new file mode 100644 index 000000000..e66315c59 --- /dev/null +++ b/Where/WhereCore/Sources/Compatibility/DataCompatibilityTransport.swift @@ -0,0 +1,47 @@ +import Foundation + +/// One server observation. The opaque revision is used for a conditional version increase. +public struct SharedDataModelVersion: Sendable, Equatable { + public let version: DataModelVersion + public let revision: String + + public init(version: DataModelVersion, revision: String) { + self.version = version + self.revision = revision + } +} + +public enum DataCompatibilityTransportError: Error { + case unavailable(any Error) + case conflict + case invalidRecord + case accountChanged +} + +/// Control-plane I/O, independent of SwiftData and the services protected by the gate. +public protocol DataCompatibilityTransport: Sendable { + func accountID() async throws -> DataCompatibilityAccountID + func read(accountID: DataCompatibilityAccountID) async throws -> SharedDataModelVersion? + func advance( + accountID: DataCompatibilityAccountID, + from previous: SharedDataModelVersion?, + to version: DataModelVersion, + ) async throws -> SharedDataModelVersion + func accountChanges() -> AsyncStream +} + +public enum DataCompatibilityFailure: Error, LocalizedError { + case updateRequired(DataModelVersion) + case recordingDeviceRequired + case locationPermissionRequired + case verificationRequired(any Error) + + public var errorDescription: String? { + switch self { + case .updateRequired: String(localized: .compatibilityErrorUpdate) + case .recordingDeviceRequired: String(localized: .compatibilityErrorRecording) + case .locationPermissionRequired: String(localized: .compatibilityErrorPermission) + case .verificationRequired: String(localized: .compatibilityErrorVerify) + } + } +} diff --git a/Where/WhereCore/Sources/Compatibility/DataModelVersion.swift b/Where/WhereCore/Sources/Compatibility/DataModelVersion.swift new file mode 100644 index 000000000..84fdc171d --- /dev/null +++ b/Where/WhereCore/Sources/Compatibility/DataModelVersion.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Account-wide data semantics. Increase only before shipping an incompatible data change. +/// Version 1 also describes history written before the compatibility gate existed. +public struct DataModelVersion: RawRepresentable, Codable, Hashable, Comparable, Sendable { + public static let current = Self(rawValue: 1) + public static let baseline = Self(rawValue: 1) + public let rawValue: Int + + public init(rawValue: Int) { + precondition(rawValue > 0) + self.rawValue = rawValue + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(Int.self) + guard value > 0 else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "A data compatibility version must be positive.", + ) + } + rawValue = value + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +/// CloudKit's opaque user-record identity, scoped to the configured container. +public struct DataCompatibilityAccountID: RawRepresentable, Codable, Hashable, Sendable { + public let rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue + } +} diff --git a/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift b/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift index 61db078ef..392c338eb 100644 --- a/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift +++ b/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift @@ -74,6 +74,11 @@ public actor DataIssueScanner { invalidationTask?.cancel() } + func stopObserving() async { + invalidationTask?.cancel() + await invalidationTask?.value + } + public func issues( year: Int, primaryRegions: [Region], diff --git a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift index f22882aa7..62dd052e0 100644 --- a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift +++ b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift @@ -34,6 +34,7 @@ public actor DeviceRecordingController { private var isExclusive = false private var waiters: [CheckedContinuation] = [] private var acceptsOperations = true + private var isRetired = false private var recordingLifecycleStarted = false private var shouldResumeAfterPause = false private var isRewritePaused = false @@ -91,6 +92,7 @@ public actor DeviceRecordingController { /// Observe local commits and CloudKit imports for removal, status, and heartbeat changes. public func startMonitoringChanges() { + guard !isRetired else { return } recordingLifecycleStarted = true guard observationTask == nil else { return } let updates = store.changes() @@ -272,6 +274,10 @@ public actor DeviceRecordingController { func resumeAfterImport(discardPendingSamples: Bool) async throws { await beginExclusive() + guard !isRetired else { + endExclusive() + throw CancellationError() + } acceptsOperations = true let shouldResume = shouldResumeAfterPause if discardPendingSamples { @@ -345,7 +351,23 @@ public actor DeviceRecordingController { } } + /// Permanently stop this scope, retaining consent and accepted outbox entries for recovery. + public func shutdown() async { + isRetired = true + acceptsOperations = false + await ingestor.retire() + await beginExclusive() + recordingLifecycleStarted = false + let observation = observationTask + observation?.cancel() + observationTask = nil + await ingestor.shutdown() + endExclusive() + await observation?.value + } + private func resumeLocked() async { + guard !isRetired else { return } acceptsOperations = true isRewritePaused = false let shouldResume = shouldResumeAfterPause @@ -604,7 +626,7 @@ public actor DeviceRecordingController { } private func requireActive() throws { - guard acceptsOperations else { throw CancellationError() } + guard acceptsOperations, !isRetired else { throw CancellationError() } } private func beginExclusive() async { diff --git a/Where/WhereCore/Sources/Location/LocationIngestor.swift b/Where/WhereCore/Sources/Location/LocationIngestor.swift index a4115213d..1dee0e05f 100644 --- a/Where/WhereCore/Sources/Location/LocationIngestor.swift +++ b/Where/WhereCore/Sources/Location/LocationIngestor.swift @@ -64,6 +64,7 @@ public actor LocationIngestor { /// separately from `ingestTask` because the ingestion task outlives a /// `stop()` pause (see `start()` for why). private var isMonitoring = false + private var isRetired = false /// Whether the resolved device policy currently authorizes automatic samples. Closed before /// registration, while policy is Off/unavailable, and during teardown. This is independent @@ -139,9 +140,14 @@ public actor LocationIngestor { /// already-finished stream and silently drop every subsequent sample. public func start(effectiveAt: Date, dataGenerationID: WhereDataGenerationID) async throws { try await authorizeRecording(effectiveAt: effectiveAt, dataGenerationID: dataGenerationID) + guard !isRetired else { throw CancellationError() } guard !isMonitoring else { return } isMonitoring = true await locationSource.start() + guard !isRetired else { + await locationSource.stop() + throw CancellationError() + } guard isMonitoring else { return } Self.logger.monitoringStarted() installIngestTaskIfNeeded() @@ -154,6 +160,7 @@ public actor LocationIngestor { effectiveAt: Date, dataGenerationID: WhereDataGenerationID, ) async throws { + guard !isRetired else { throw CancellationError() } let currentGenerationID: WhereDataGenerationID do { currentGenerationID = try await store @@ -162,6 +169,7 @@ public actor LocationIngestor { await closeRecordingAuthority() throw error } + guard !isRetired else { throw CancellationError() } guard currentGenerationID == dataGenerationID else { await closeRecordingAuthority() throw RecordingPersistenceError.dataGenerationChanged @@ -190,6 +198,7 @@ public actor LocationIngestor { guard confirmedGenerationID == dataGenerationID else { throw RecordingPersistenceError.dataGenerationChanged } + guard !isRetired else { throw CancellationError() } recordingAuthority = .open(dataGenerationID: dataGenerationID, effectiveAt: effectiveAt) await Self.logger.measure(.postPersist, budget: .seconds(2)) { await onPersisted(IngestOutcome( @@ -217,7 +226,7 @@ public actor LocationIngestor { } private func installIngestTaskIfNeeded() { - guard ingestTask == nil else { return } + guard !isRetired, ingestTask == nil else { return } let stream = locationSource.sampleStream ingestTask = Task { [weak self] in for await sample in stream { @@ -281,6 +290,25 @@ public actor LocationIngestor { await revokeRecordingAuthorization() } + /// Permanently closes this scope before its controller joins work already in flight. + func retire() async { + isRetired = true + await pause() + } + + /// Scope retirement retains the durable outbox but joins every owned stream/capture task. + func shutdown() async { + await retire() + let ingestion = ingestTask + let capture = captureTask + ingestion?.cancel() + capture?.cancel() + await ingestion?.value + await capture?.value + ingestTask = nil + captureTask = nil + } + /// Pause GPS ingestion by stopping the underlying location monitoring. /// Idempotent and safe to call from teardown paths that may run before any /// `start()`. The ingestion task is intentionally left running (see diff --git a/Where/WhereCore/Sources/Logging/DataCompatibilityLog.swift b/Where/WhereCore/Sources/Logging/DataCompatibilityLog.swift new file mode 100644 index 000000000..a93f36f3f --- /dev/null +++ b/Where/WhereCore/Sources/Logging/DataCompatibilityLog.swift @@ -0,0 +1,24 @@ +import PeriscopeCore + +@LogScope("DataCompatibility") +enum DataCompatibilityLog { + @LogEvent("verification-failed", level: .warning) + struct VerificationFailed { + @LogField(exposure: .restricted, kind: .errorDetails) + var error: LogError + var message: String { + "Data compatibility verification failed: \(error.description)" + } + } + + @LogEvent("blocked", level: .warning) + struct Blocked { + @LogField(exposure: .restricted, kind: .technicalState) + var reason: String + var message: String { + "History access blocked: \(reason)" + } + } + + enum SpanName: String { case verify } +} diff --git a/Where/WhereCore/Sources/Persistence/RemoteDataChangeReconciler.swift b/Where/WhereCore/Sources/Persistence/RemoteDataChangeReconciler.swift index ed332e98e..0f543b83c 100644 --- a/Where/WhereCore/Sources/Persistence/RemoteDataChangeReconciler.swift +++ b/Where/WhereCore/Sources/Persistence/RemoteDataChangeReconciler.swift @@ -23,4 +23,9 @@ final class RemoteDataChangeReconciler: @unchecked Sendable { deinit { task.cancel() } + + func stop() async { + task.cancel() + await task.value + } } diff --git a/Where/WhereCore/Sources/RegionAttribution.swift b/Where/WhereCore/Sources/RegionAttribution.swift index b5f449573..500187bce 100644 --- a/Where/WhereCore/Sources/RegionAttribution.swift +++ b/Where/WhereCore/Sources/RegionAttribution.swift @@ -80,6 +80,11 @@ final class RegionAttribution: RegionAttributing { observer?.cancel() } + func stop() async { + observer?.cancel() + await observer?.value + } + private var current: RegionAttributor { state.withLock { $0.attributor } } diff --git a/Where/WhereCore/Sources/Resources/Localizable.xcstrings b/Where/WhereCore/Sources/Resources/Localizable.xcstrings index 7626cf944..cd57b8f49 100644 --- a/Where/WhereCore/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereCore/Sources/Resources/Localizable.xcstrings @@ -78,6 +78,50 @@ } } }, + "compatibility.error.permission" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allow Always location access on your recording device, then open Where." + } + } + } + }, + "compatibility.error.recording" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open the latest Where on your recording device before using this version." + } + } + } + }, + "compatibility.error.update" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update Where to open data used by a newer version." + } + } + } + }, + "compatibility.error.verify" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Where and connect to iCloud to verify data compatibility." + } + } + } + }, "dataIssues.notification.body" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index 99069a123..6b982a50c 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -102,6 +102,20 @@ public struct WhereServices: Sendable { let installationContext: InstallationRecordingContext /// Owns the remote-import observation task for this service lifetime. private let remoteDataChangeReconciler: RemoteDataChangeReconciler + + /// Lifecycle observation only; consumers do not use this as a second data read path. + public func remoteDataChangeUpdates() -> AsyncStream { + store.remoteChanges() + } + + /// Retire owned loops and GPS without erasing user data or the durable retry outbox. + public func shutdown() async { + await recording.shutdown() + await remoteDataChangeReconciler.stop() + await (attributor as? RegionAttribution)?.stop() + await resolution.stopObserving() + } + /// Synchronous assembly with an explicitly-provided `attributor` (default: /// the historical four via `RegionAttributor.shared`). For **tests and /// previews** — hence `@_spi(Testing)` — which build in-memory stacks without diff --git a/Where/WhereCore/Tests/DataCompatibilityCoordinatorTests.swift b/Where/WhereCore/Tests/DataCompatibilityCoordinatorTests.swift new file mode 100644 index 000000000..a820d6955 --- /dev/null +++ b/Where/WhereCore/Tests/DataCompatibilityCoordinatorTests.swift @@ -0,0 +1,255 @@ +import Foundation +import TestHostSupport +import Testing +@testable import WhereCore + +@MainActor +struct DataCompatibilityCoordinatorTests { + private let deviceID = RecordingDeviceID(rawValue: UUID()) + private let offline = DataCompatibilityTransportError + .unavailable(URLError(.notConnectedToInternet)) + + @Test(arguments: [nil, 1]) + func baselineViewersOpenWithoutPublishing(shared: Int?) async { + let transport = ScriptedDataCompatibilityTransport(version: shared) + let receipts = InMemoryDataCompatibilityReceipt() + let coordinator = DataCompatibilityCoordinator( + transport: transport, + receipts: receipts, + version: .baseline, + ) + await coordinator.refresh { .disabled } + #expect(coordinator.state.allowsStore) + #expect(await transport.saveCount == 0) + #expect(receipts.receipt?.access == .verified) + } + + @Test func newerDataLocksEveryDevice() async { + let transport = ScriptedDataCompatibilityTransport(version: 3) + let receipts = InMemoryDataCompatibilityReceipt() + let coordinator = DataCompatibilityCoordinator( + transport: transport, + receipts: receipts, + version: .init(rawValue: 2), + ) + await coordinator.refresh { .recording(deviceID) } + guard case .blocked(.updateRequired(.init(rawValue: 3))) = coordinator.state else { + Issue.record("Newer shared data must block this build"); return + } + #expect(await transport.saveCount == 0) + #expect(receipts.receipt?.version.rawValue == 3) + #expect(receipts.receipt?.access == .blocked) + } + + @Test func onlyRecorderCanAdvanceAndWaitingViewerRecovers() async { + let transport = ScriptedDataCompatibilityTransport(version: 1) + let viewer = DataCompatibilityCoordinator( + transport: transport, + receipts: InMemoryDataCompatibilityReceipt(), + version: .init(rawValue: 2), + ) + await viewer.refresh { .disabled } + #expect(!viewer.state.allowsStore) + #expect(await transport.saveCount == 0) + let recorder = DataCompatibilityCoordinator( + transport: transport, + receipts: InMemoryDataCompatibilityReceipt(), + version: .init(rawValue: 2), + ) + await recorder.refresh { .recording(deviceID) } + #expect(recorder.state.allowsStore) + #expect(await transport.saveCount == 1) + await viewer.refresh { .disabled } + #expect(viewer.state.allowsStore) + } + + @Test func missingPermissionCannotAdvance() async { + let transport = ScriptedDataCompatibilityTransport(version: 1) + let coordinator = DataCompatibilityCoordinator( + transport: transport, + receipts: InMemoryDataCompatibilityReceipt(), + version: .init(rawValue: 2), + ) + await coordinator.refresh { .permissionRequired } + guard case .blocked(.locationPermissionRequired) = coordinator.state else { + Issue.record("Expected permission recovery"); return + } + #expect(await transport.saveCount == 0) + } + + @Test func offlineAccessSurvivesRelaunchButNotAnUpgrade() async { + let transport = ScriptedDataCompatibilityTransport(version: 1) + let receipts = InMemoryDataCompatibilityReceipt() + let first = DataCompatibilityCoordinator( + transport: transport, + receipts: receipts, + version: .baseline, + ) + await first.refresh { .disabled } + await transport.setReadError(offline) + let second = DataCompatibilityCoordinator( + transport: transport, + receipts: receipts, + version: .baseline, + ) + await second.refresh { .disabled } + guard case .offline = second.state + else { Issue.record("Expected verified offline access"); return } + let upgraded = DataCompatibilityCoordinator( + transport: transport, + receipts: receipts, + version: .init(rawValue: 2), + ) + await upgraded.refresh { .recording(deviceID) } + #expect(!upgraded.state.allowsStore) + } + + @Test func uncertainUpgradeRequiresReadback() async { + let transport = ScriptedDataCompatibilityTransport(version: 1) + let receipts = InMemoryDataCompatibilityReceipt() + let coordinator = DataCompatibilityCoordinator( + transport: transport, + receipts: receipts, + version: .init(rawValue: 2), + ) + await transport.setSaveError(offline) + await coordinator.refresh { .recording(deviceID) } + #expect(!coordinator.state.allowsStore) + #expect(await transport.remote?.version.rawValue == 2) + await coordinator.refresh { .recording(deviceID) } + #expect(coordinator.state.allowsStore) + #expect(await transport.saveCount == 1) + } + + @Test func conflictWithNewerWriterBlocksInsteadOfDowngrading() async { + let transport = ScriptedDataCompatibilityTransport(version: 1) + await transport.conflict(with: 3) + let coordinator = DataCompatibilityCoordinator( + transport: transport, + receipts: InMemoryDataCompatibilityReceipt(), + version: .init(rawValue: 2), + ) + await coordinator.refresh { .recording(deviceID) } + #expect(!coordinator.state.allowsStore) + #expect(await transport.remote?.version.rawValue == 3) + #expect(await transport.saveCount == 1) + } + + @Test func accountChangeRejectsSuspendedCompletion() async throws { + let transport = ScriptedDataCompatibilityTransport(version: 1) + let coordinator = DataCompatibilityCoordinator( + transport: transport, + receipts: InMemoryDataCompatibilityReceipt(), + version: .baseline, + ) + await transport.holdRead() + let refresh = Task { await coordinator.refresh { .disabled } } + try await waitForCompatibility { await transport.isReadHeld } + await transport.changeAccount() + try await waitForCompatibility { + if case .blocked = coordinator.state { true } else { false } + } + await transport.releaseRead() + await refresh.value + #expect(!coordinator.state.allowsStore) + } + + @Test func overlappingTriggersShareOneVerification() async throws { + let transport = ScriptedDataCompatibilityTransport(version: 1) + let coordinator = DataCompatibilityCoordinator( + transport: transport, + receipts: InMemoryDataCompatibilityReceipt(), + version: .baseline, + ) + await transport.holdRead() + let first = Task { await coordinator.refresh { .disabled } } + try await waitForCompatibility { await transport.isReadHeld } + var secondEntered = false + let second = Task { + secondEntered = true + await coordinator.refresh { .disabled } + } + try await waitForCompatibility { secondEntered } + await transport.releaseRead() + await first.value + await second.value + #expect(await transport.readCount == 1) + #expect(coordinator.state.allowsStore) + } + + @Test func accountNotificationBeforeFirstCheckPreservesTheCachedFloor() async throws { + let transport = ScriptedDataCompatibilityTransport(version: 1) + let receipts = InMemoryDataCompatibilityReceipt(receipt: .init( + accountID: .init(rawValue: "account-a"), + version: .init(rawValue: 3), + access: .verified, + removedDeviceID: deviceID, + )) + let coordinator = DataCompatibilityCoordinator( + transport: transport, + receipts: receipts, + version: .init(rawValue: 2), + ) + await transport.changeAccount() + try await waitForCompatibility { receipts.receipt?.access == .blocked } + #expect(receipts.receipt?.version.rawValue == 3) + #expect(receipts.receipt?.removedDeviceID == deviceID) + await coordinator.refresh { .recording(deviceID) } + #expect(!coordinator.state.allowsStore) + #expect(await transport.saveCount == 0) + } + + @Test func removedInstallationCannotPublishOnRelaunch() async { + let transport = ScriptedDataCompatibilityTransport(version: 1) + let receipts = InMemoryDataCompatibilityReceipt() + let first = DataCompatibilityCoordinator( + transport: transport, + receipts: receipts, + version: .baseline, + ) + await first.refresh { .recording(deviceID) } + first.recordRemoval(of: deviceID) + let next = DataCompatibilityCoordinator( + transport: transport, + receipts: receipts, + version: .init(rawValue: 2), + ) + await next.refresh { .recording(deviceID) } + #expect(!next.state.allowsStore) + #expect(await transport.saveCount == 0) + } + + @Test func receiptFailureDoesNotGrantAccess() async { + let transport = ScriptedDataCompatibilityTransport(version: 1) + let receipts = InMemoryDataCompatibilityReceipt() + receipts.failure = CocoaError(.fileReadCorruptFile) + let coordinator = DataCompatibilityCoordinator( + transport: transport, + receipts: receipts, + version: .baseline, + ) + await coordinator.refresh { .disabled } + #expect(!coordinator.state.allowsStore) + #expect(await transport.readCount == 0) + } + + @Test(arguments: [nil, 1]) + func higherCacheCannotSubstituteForSharedPublication(shared: Int?) async { + let transport = ScriptedDataCompatibilityTransport(version: shared) + let receipts = InMemoryDataCompatibilityReceipt(receipt: .init( + accountID: .init(rawValue: "account-a"), + version: .init(rawValue: 2), + access: .verified, + removedDeviceID: nil, + )) + let coordinator = DataCompatibilityCoordinator( + transport: transport, + receipts: receipts, + version: .init(rawValue: 2), + ) + await coordinator.refresh { .disabled } + #expect(!coordinator.state.allowsStore) + #expect(await transport.saveCount == 0) + #expect(receipts.receipt?.access == .blocked) + } +} diff --git a/Where/WhereCore/Tests/DataCompatibilityReceiptTests.swift b/Where/WhereCore/Tests/DataCompatibilityReceiptTests.swift new file mode 100644 index 000000000..a57662898 --- /dev/null +++ b/Where/WhereCore/Tests/DataCompatibilityReceiptTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing +@testable import WhereCore + +@MainActor +struct DataCompatibilityReceiptTests { + @Test func fileRoundTripIsExcludedFromBackupAndBlocksOtherVersions() throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { + do { try FileManager.default.removeItem(at: directory) } + catch { Issue.record(error) } + } + let store = FileDataCompatibilityReceipt(directory: directory) + #expect(try store.load() == nil) + try store.save(.init( + accountID: .init(rawValue: "test-account"), + version: .init(rawValue: 2), + access: .verified, + removedDeviceID: nil, + )) + let receipt = try #require(try store.load()) + try receipt.requireAccess(version: .init(rawValue: 2)) + #expect(throws: DataCompatibilityFailure.self) { + try receipt.requireAccess(version: .baseline) + } + #expect(try directory.resourceValues(forKeys: [.isExcludedFromBackupKey]) + .isExcludedFromBackup == true) + try store.save(.init( + accountID: receipt.accountID, + version: receipt.version, + access: .blocked, + removedDeviceID: nil, + )) + #expect(throws: DataCompatibilityFailure.self) { + try #require(try store.load()).requireAccess(version: .init(rawValue: 2)) + } + } + + @Test func corruptReceiptIsNotMissingVerification() throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { + do { try FileManager.default.removeItem(at: directory) } + catch { Issue.record(error) } + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("invalid".utf8).write(to: directory.appending(path: "receipt.json")) + let store = FileDataCompatibilityReceipt(directory: directory) + #expect(throws: DecodingError.self) { try store.load() } + } + + @Test(arguments: [0, -1]) + func nonpositiveReceiptVersionThrows(value: Int) throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { + do { try FileManager.default.removeItem(at: directory) } + catch { Issue.record(error) } + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try JSONSerialization.data(withJSONObject: [ + "accountID": "account-a", + "version": value, + "access": "verified", + ]) + try data.write(to: directory.appending(path: "receipt.json")) + #expect(throws: DecodingError.self) { + try FileDataCompatibilityReceipt(directory: directory).load() + } + } +} diff --git a/Where/WhereCore/Tests/DataModelVersionTests.swift b/Where/WhereCore/Tests/DataModelVersionTests.swift new file mode 100644 index 000000000..116f80e5b --- /dev/null +++ b/Where/WhereCore/Tests/DataModelVersionTests.swift @@ -0,0 +1,18 @@ +import Foundation +import Testing +@testable import WhereCore + +struct DataModelVersionTests { + @Test(arguments: [0, -1]) + func invalidWireVersionThrows(value: Int) { + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(DataModelVersion.self, from: Data("\(value)".utf8)) + } + } + + @Test func wireFormatIsAnInteger() throws { + let version = DataModelVersion(rawValue: 2) + #expect(try JSONEncoder().encode(version) == Data("2".utf8)) + #expect(try JSONDecoder().decode(DataModelVersion.self, from: Data("2".utf8)) == version) + } +} diff --git a/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift index 5b068014a..4e4cdb5d1 100644 --- a/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift +++ b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift @@ -4,6 +4,17 @@ import Testing @_spi(Testing) @testable import WhereCore struct DeviceRecordingControllerTests { + @Test func committedImportCannotResumeRetiredRecording() async throws { + let (controller, _, ingestor, _) = try makeController(enabled: true) + _ = try await controller.register(authorization: .always) + await controller.shutdown() + await #expect(throws: CancellationError.self) { + try await controller.resumeAfterImport(discardPendingSamples: false) + } + await controller.resumeAfterImportRollback() + #expect(await !ingestor.isActive) + } + private struct WaitTimeout: Error {} private static let now = Date(timeIntervalSinceReferenceDate: 1000) diff --git a/Where/WhereCore/Tests/LocationIngestorTests.swift b/Where/WhereCore/Tests/LocationIngestorTests.swift index b5fe03629..4414542b7 100644 --- a/Where/WhereCore/Tests/LocationIngestorTests.swift +++ b/Where/WhereCore/Tests/LocationIngestorTests.swift @@ -6,6 +6,44 @@ import Testing /// Covers the GPS ingestion lifecycle, the post-persist hook, and the retry /// queue the controller delegates all of `startGPS`/`stopGPS`/auth to. struct LocationIngestorTests { + @Test func shutdownRejectsAuthorizationSuspendedInOutboxLoad() async throws { + let store = try SwiftDataStore.inMemory() + let source = ScriptedLocationSource(authorizationStatus: .always) + let outbox = SuspendingLocationOutbox() + let ingestor = Self.makeIngestor( + store: store, + source: source, + recorder: OutcomeRecorder(), + outbox: outbox, + ) + let start = Task { try await ingestor.start() } + try await waitForCompatibility { await outbox.suspension.isHeld } + await ingestor.shutdown() + await outbox.suspension.release() + await #expect(throws: CancellationError.self) { try await start.value } + #expect(await !ingestor.isActive) + await #expect(throws: CancellationError.self) { try await ingestor.start() } + } + + @Test func shutdownRejectsPhysicalStartAfterSuspendedOutput() async throws { + let store = try SwiftDataStore.inMemory() + let source = ScriptedLocationSource(authorizationStatus: .always) + let output = RecordingRetirementSuspension() + let ingestor = LocationIngestor( + store: store, + locationSource: source, + recordingDeviceID: CurrentRecordingDevice.preview.id, + calendar: WhereCoreTestSupport.calendar(), + onPersisted: { _ in await output.hold() }, + ) + let start = Task { try await ingestor.start() } + try await waitForCompatibility { await output.isHeld } + await ingestor.shutdown() + await output.release() + await #expect(throws: CancellationError.self) { try await start.value } + #expect(await !ingestor.isActive) + } + private enum OutboxFailure: Error { case clear case save diff --git a/Where/WhereCore/Tests/Support/DataCompatibilityTestSupport.swift b/Where/WhereCore/Tests/Support/DataCompatibilityTestSupport.swift new file mode 100644 index 000000000..638993db8 --- /dev/null +++ b/Where/WhereCore/Tests/Support/DataCompatibilityTestSupport.swift @@ -0,0 +1,119 @@ +import Foundation +@testable import WhereCore + +actor ScriptedDataCompatibilityTransport: DataCompatibilityTransport { + var account = DataCompatibilityAccountID(rawValue: "account-a") + var remote: SharedDataModelVersion? + var readError: (any Error)? + var saveError: (any Error)? + var saveCount = 0 + var readCount = 0 + var conflictVersion: DataModelVersion? + private var heldRead: CheckedContinuation? + private var holdsRead = false + private nonisolated let stream: AsyncStream + private nonisolated let continuation: AsyncStream.Continuation + + init(version: Int?) { + remote = version.map { .init(version: .init(rawValue: $0), revision: "initial") } + (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + } + + func accountID() async throws -> DataCompatibilityAccountID { + account + } + + nonisolated func accountChanges() -> AsyncStream { + stream + } + + func read(accountID _: DataCompatibilityAccountID) async throws -> SharedDataModelVersion? { + readCount += 1 + let captured = remote + if holdsRead { await withCheckedContinuation { heldRead = $0 } } + if let readError { throw readError } + return captured + } + + func advance( + accountID _: DataCompatibilityAccountID, + from previous: SharedDataModelVersion?, + to version: DataModelVersion, + ) async throws -> SharedDataModelVersion { + saveCount += 1 + if let conflictVersion { + self.conflictVersion = nil + remote = .init(version: conflictVersion, revision: "conflict") + throw DataCompatibilityTransportError.conflict + } + guard previous == remote else { throw DataCompatibilityTransportError.conflict } + remote = .init(version: version, revision: "save-\(saveCount)") + if let saveError { throw saveError } + return remote! + } + + func setReadError(_ error: (any Error)?) { + readError = error + } + + func setSaveError(_ error: (any Error)?) { + saveError = error + } + + func conflict(with version: Int) { + conflictVersion = .init(rawValue: version) + } + + func setVersion(_ version: Int) { + remote = .init( + version: .init(rawValue: version), + revision: "changed", + ) + } + + func holdRead() { + holdsRead = true + } + + var isReadHeld: Bool { + heldRead != nil + } + + func releaseRead() { + holdsRead = false; heldRead?.resume(); heldRead = nil + } + + func changeAccount() { + account = .init(rawValue: "account-b") + continuation.yield() + } +} + +@MainActor +final class InMemoryDataCompatibilityReceipt: DataCompatibilityReceiptStoring { + var receipt: DataCompatibilityReceipt? + var failure: (any Error)? + + init(receipt: DataCompatibilityReceipt? = nil) { + self.receipt = receipt + } + + func load() throws -> DataCompatibilityReceipt? { + if let failure { throw failure } + return receipt + } + + func save(_ receipt: DataCompatibilityReceipt) throws { + if let failure { throw failure } + self.receipt = receipt + } +} + +@MainActor +func waitForCompatibility(_ predicate: () async -> Bool) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while await !predicate() { + guard ContinuousClock.now < deadline else { throw URLError(.timedOut) } + try await Task.sleep(for: .milliseconds(1)) + } +} diff --git a/Where/WhereCore/Tests/Support/RecordingRetirementTestSupport.swift b/Where/WhereCore/Tests/Support/RecordingRetirementTestSupport.swift new file mode 100644 index 000000000..6b9702386 --- /dev/null +++ b/Where/WhereCore/Tests/Support/RecordingRetirementTestSupport.swift @@ -0,0 +1,30 @@ +import Foundation +@testable import WhereCore + +actor RecordingRetirementSuspension { + private var continuation: CheckedContinuation? + var isHeld: Bool { + continuation != nil + } + + func hold() async { + await withCheckedContinuation { continuation = $0 } + } + + func release() { + continuation?.resume() + continuation = nil + } +} + +actor SuspendingLocationOutbox: LocationOutbox { + let suspension = RecordingRetirementSuspension() + + func load() async throws -> [LocationOutboxEntry] { + await suspension.hold() + return [] + } + + func save(_: [LocationOutboxEntry]) async throws {} + func clear() async throws {} +} From ac42349b0a2aa01c9d1fa9aca0eaf91a600acf3e Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 14 Nov 2026 12:57:14 -0500 Subject: [PATCH 2/2] feat(Where): gate cloud scopes and retire incompatible sessions Verify the independent data version before bootstrap, onboarding discovery, and backup recovery can open history. Keep foreground, push, and import monitoring outside the disposable data scope. On lockout, hide history, stop recording, drain owned work, and recover through the launch gate. Only recording installations with Always permission may advance the shared version. Block intent admission and require a matching local receipt in the share extension. Add lockout UI, regression coverage, snapshots, ownership lint, and deployment guidance. Validation: full iOS, host, and architecture suites passed; focused lockout, root, and onboarding snapshots passed. Physical two-device CloudKit checks and production schema deployment remain release validation. --- .bumper/RULES.md | 7 + .bumper/Sources/WhereProjectRules.swift | 6 + .bumper/Tests/WhereProjectRulesTests.swift | 20 ++ Where/AGENTS.md | 4 + Where/Where/AGENTS.md | 5 + Where/Where/README.md | 26 +++ Where/Where/Sources/AppDelegate.swift | 7 + .../Sources/RegularApplicationRuntime.swift | 41 +++- .../Sources/WhereApplicationRuntime.swift | 11 ++ Where/WhereIntents/AGENTS.md | 4 +- Where/WhereIntents/README.md | 7 +- .../WhereIntents/Sources/IntentServices.swift | 42 +++-- .../Tests/IntentServicesTests.swift | 14 ++ Where/WhereShareExtension/AGENTS.md | 5 + Where/WhereShareExtension/README.md | 4 + .../Resources/Localizable.xcstrings | 11 ++ .../Sources/ShareEvidenceModel.swift | 14 ++ .../Sources/ShareEvidenceView.swift | 2 +- .../Sources/ShareViewController.swift | 5 + .../Sources/WhereShareBuildEnvironment.swift | 8 + Where/WhereUI/AGENTS.md | 5 + Where/WhereUI/README.md | 12 +- .../DataCompatibilityViewSnapshotTests.swift | 10 + .../dataCompatibility.Permission_iPhone.png | 3 + ...taCompatibility.Permission_iPhone_dark.png | 3 + ...taCompatibility.RecordingDevice_iPhone.png | 3 + ...patibility.RecordingDevice_iPhone_dark.png | 3 + .../dataCompatibility.UpdateRequired_iPad.png | 3 + ...lity.UpdateRequired_iPad_accessibility.png | 3 + ...Compatibility.UpdateRequired_iPad_dark.png | 3 + ...ataCompatibility.UpdateRequired_iPhone.png | 3 + ...ty.UpdateRequired_iPhone_accessibility.png | 3 + ...mpatibility.UpdateRequired_iPhone_dark.png | 3 + .../dataCompatibility.Verification_iPhone.png | 3 + ...Compatibility.Verification_iPhone_dark.png | 3 + ...aCompatibility.UpdateRequired_iPad_ax5.png | 3 + ...atibility.UpdateRequired_iPad_contrast.png | 3 + ...ompatibility.UpdateRequired_iPhone_ax5.png | 3 + ...ibility.UpdateRequired_iPhone_contrast.png | 3 + .../Compatibility/DataCompatibilityView.swift | 127 +++++++++++++ .../WhereCompatibilityModel.swift | 129 +++++++++++++ .../Flyover/WhereFlyoverCatalog.swift | 1 + .../WhereUI/Sources/Launch/WhereLaunch.swift | 77 +++++++- .../Sources/Launch/WhereLaunchSteps.swift | 24 ++- Where/WhereUI/Sources/Model/WhereModel.swift | 127 ++++++++++++- .../WhereUI/Sources/Model/WhereSession.swift | 15 ++ .../Onboarding/OnboardingFlowModel.swift | 17 +- .../OnboardingImportRecoveryModel.swift | 5 + .../Sources/Onboarding/OnboardingView.swift | 5 +- .../Sources/Resources/Localizable.xcstrings | 132 +++++++++++++ Where/WhereUI/Sources/RootView.swift | 116 +++++++----- .../OnboardingImportRecoveryModelTests.swift | 30 +++ .../Support/CompatibilityLaunchSupport.swift | 126 +++++++++++++ .../Tests/WhereCompatibilityModelTests.swift | 175 ++++++++++++++++++ Where/WhereUI/Tests/WhereLaunchTests.swift | 1 + 55 files changed, 1344 insertions(+), 81 deletions(-) create mode 100644 Where/WhereUI/SnapshotTests/DataCompatibilityViewSnapshotTests.swift create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.Permission_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.Permission_iPhone_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.RecordingDevice_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.RecordingDevice_iPhone_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPad.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPad_accessibility.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPad_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPhone_accessibility.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPhone_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.Verification_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.Verification_iPhone_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/other/dataCompatibility.UpdateRequired_iPad_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/other/dataCompatibility.UpdateRequired_iPad_contrast.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/other/dataCompatibility.UpdateRequired_iPhone_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/other/dataCompatibility.UpdateRequired_iPhone_contrast.png create mode 100644 Where/WhereUI/Sources/Compatibility/DataCompatibilityView.swift create mode 100644 Where/WhereUI/Sources/Compatibility/WhereCompatibilityModel.swift create mode 100644 Where/WhereUI/Tests/Support/CompatibilityLaunchSupport.swift create mode 100644 Where/WhereUI/Tests/WhereCompatibilityModelTests.swift diff --git a/.bumper/RULES.md b/.bumper/RULES.md index ce91963d5..a29a287d4 100644 --- a/.bumper/RULES.md +++ b/.bumper/RULES.md @@ -75,6 +75,13 @@ through injection. Add another owner only when a genuinely separate process is introduced and its module documentation names that composition root. Delete the rule if production store construction becomes compiler-enforced by the API. +## Compatibility transport ownership + +`where.compatibility_transport_ownership` confines production construction of `CloudKitDataCompatibilityTransport` to `RegularApplicationRuntime`. +Inject its coordinator into consumers. The mutation test accepts runtime construction and rejects a competing UI owner with error severity. +This rule checks construction ownership, not runtime admission or shutdown ordering. `WhereCompatibilityModelTests` and Core compatibility tests cover those decisions. +Remove the rule if the type system enforces construction ownership. + ## Checked concurrency boundaries `where.checked_concurrency_boundaries` rejects `@preconcurrency` throughout diff --git a/.bumper/Sources/WhereProjectRules.swift b/.bumper/Sources/WhereProjectRules.swift index 274ae722f..8b21fbdc1 100644 --- a/.bumper/Sources/WhereProjectRules.swift +++ b/.bumper/Sources/WhereProjectRules.swift @@ -18,6 +18,12 @@ let whereProjectRules = RuleSet { owner: whereLoggingScope.union(nonWhereProductionScope), id: "where.logging_type_ownership", ) + Rules.constructionOwnership( + "CloudKitDataCompatibilityTransport", + allowed: RuleScope.files(["Where/Where/Sources/RegularApplicationRuntime.swift"]) + .union(nonWhereProductionScope), + id: "where.compatibility_transport_ownership", + ) productionStoreOpeningRule checkedConcurrencyBoundaryRule gregorianCalendarRule diff --git a/.bumper/Tests/WhereProjectRulesTests.swift b/.bumper/Tests/WhereProjectRulesTests.swift index 4ab1edf3d..0f946b125 100644 --- a/.bumper/Tests/WhereProjectRulesTests.swift +++ b/.bumper/Tests/WhereProjectRulesTests.swift @@ -3,6 +3,26 @@ import BumperBowlingTestSupport import Testing struct WhereProjectRulesTests { + @Test + func `compatibility transport belongs to the regular runtime`() throws { + let source = "func assemble() { _ = CloudKitDataCompatibilityTransport(containerIdentifier: \"test\") }" + let allowed = try evaluate( + path: "Where/Where/Sources/RegularApplicationRuntime.swift", + component: .app, + source: source, + ) + let rejected = try evaluate( + path: "Where/WhereUI/Sources/Model/CompetingCompatibilityOwner.swift", + component: .whereUI, + source: source, + ) + #expect(allowed.violations.isEmpty) + let violation = try #require(rejected.violations.first) + #expect(rejected.violations.count == 1) + #expect(violation.rule.id == "where.compatibility_transport_ownership") + #expect(violation.rule.severity == .error) + } + @Test func `production store opens at process composition roots`() throws { let allowed = try evaluate( diff --git a/Where/AGENTS.md b/Where/AGENTS.md index d5d7d421a..b98a7be33 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -121,6 +121,10 @@ a screen felt slow. ## Scopes and the launch +- Verify data compatibility before onboarding discovery, interrupted import recovery, or production scope construction. + Keep the compatibility gate active for foreground and headless launches. Local-only and demo worlds bypass CloudKit verification. + A live lockout stops recording and retires the whole scope. Guards: `WhereCompatibilityModelTests` and `WhereLaunchTests`. + - **A `WhereScope` is what the app is logged in *to*.** It is one open store's `WhereServices`, the `WherePreferences` driving it, and the durable log store they record into. It is created whole. `WhereSession` is built from one. A diff --git a/Where/Where/AGENTS.md b/Where/Where/AGENTS.md index 113ab1ef8..0d1e3891d 100644 --- a/Where/Where/AGENTS.md +++ b/Where/Where/AGENTS.md @@ -36,6 +36,11 @@ layering, and the domain rules this target merely starts up. ## Invariants +- Construct the CloudKit compatibility transport only in the regular runtime. Inject its coordinator into the model and every bootstrap. + Local-only and demo worlds must not verify or publish CloudKit metadata. + Forward compatibility subscription notifications to the process-owned monitor, including headless launches. + Guard: `where.compatibility_transport_ownership`. + - Construct InstallerKit’s update service and model once in the regular runtime. Require the Development audience, valid local or published InstallerKit metadata, and a non-test process. Inject through `RootView`; foreground UI owns checking. diff --git a/Where/Where/README.md b/Where/Where/README.md index 6608c4211..b308771b5 100644 --- a/Where/Where/README.md +++ b/Where/Where/README.md @@ -90,6 +90,32 @@ report the build/install/launch plan without performing it. ## CloudKit rollout and device validation +The regular runtime verifies a separate compatibility record before opening SwiftData. +Local-only Development builds and demo scopes bypass this protocol. +The control record uses the private `WhereStoreCompatibility` zone and record type, with `requiredVersion` (Int64) and `accountID` (String). +Its `where-store-compatibility` zone subscription requests silent notifications. Notifications are advisory; foreground polling also refreshes the decision. + +Before the first incompatible release: + +1. Use a recording-enabled validation build with Always permission to create the control record in the Development CloudKit environment. +2. Deploy its record schema to Production through CloudKit Console before distributing the cloud-enabled build. +3. Ship `DataModelVersion.current == 1` and the compatibility gate to all supported installations. +4. Increase `DataModelVersion.current` only when a later release changes data incompatibly. + +Builds that predate the gate cannot obey it. A version increase does not make an existing unsupported build safe. +The protocol stops the entire session after detection. It does not make metadata and SwiftData synchronization atomic across devices. + +Compatibility acceptance on two devices: + +1. Keep one recording device on version 1. Install a version-2 validation build on a viewer and verify that it waits without opening history. +2. Install version 2 on the recording device. Verify that it publishes before opening history and that the viewer recovers. +3. Keep a version-1 device open during that publication. Verify that it hides history, stops GPS, and releases its scope. +4. Repeat through foreground entry, a control-record push, a remote import, and Retry after a network outage. +5. After successful verification, test offline relaunch on the same account and version. CloudKit must still resolve the account identity. +6. Test an unverified upgrade, account change, missing permission, and malformed receipt. Each must remain blocked. +7. During lockout, invoke an intent and save from the share sheet. Verify an actionable error and no new store open. +8. Restore network access and the matching version. Verify one fresh scope, preserved consent, and the retained location outbox. + The app target owns `iCloud.com.stuff.where`, the Push Notifications entitlement, and the remote-notification background mode. Widgets and the share extension intentionally have only the App Group entitlement: they write/read diff --git a/Where/Where/Sources/AppDelegate.swift b/Where/Where/Sources/AppDelegate.swift index b7d10d050..5b9d79718 100644 --- a/Where/Where/Sources/AppDelegate.swift +++ b/Where/Where/Sources/AppDelegate.swift @@ -142,4 +142,11 @@ final class AppDelegate: NSObject, UIApplicationDelegate { } return runtime.didFinishLaunching(application: application, options: options) } + + func application( + _: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + ) async -> UIBackgroundFetchResult { + await runtime.didReceiveRemoteNotification(userInfo) + } } diff --git a/Where/Where/Sources/RegularApplicationRuntime.swift b/Where/Where/Sources/RegularApplicationRuntime.swift index 8e9b0b884..77823e6f7 100644 --- a/Where/Where/Sources/RegularApplicationRuntime.swift +++ b/Where/Where/Sources/RegularApplicationRuntime.swift @@ -1,4 +1,5 @@ import AppIntents +import CloudKit import InstallerKit import LifecycleKit import PeriscopeCore @@ -114,6 +115,17 @@ final class RegularApplicationRuntime: WhereApplicationRuntime { ) -> WhereModel { let installationContextStore = FileInstallationRecordingContextStore() let locationOutbox = FileLocationOutbox.applicationSupport() + let coordinator = storeStorage.usesCloudKit + ? DataCompatibilityCoordinator( + transport: CloudKitDataCompatibilityTransport( + containerIdentifier: "iCloud.com.stuff.where", + ), + receipts: AppGroupDataCompatibilityReceipt(appGroupIdentifier: buildEnvironment + .appGroupIdentifier), + version: .current, + ) + : DataCompatibilityCoordinator() + let compatibility = WhereCompatibilityModel(coordinator: coordinator) return WhereModel( preferences: preferences, installationContextStore: installationContextStore, @@ -123,23 +135,39 @@ final class RegularApplicationRuntime: WhereApplicationRuntime { storeStorage: storeStorage, widgetRefresher: buildEnvironment.makeWidgetRefresher(), locationOutbox: locationOutbox, + compatibility: coordinator, ) }, logSystem: .shared, + compatibility: compatibility, effectiveDiagnosticReportingConfiguration: effectiveDiagnosticReportingConfiguration, applyRemoteLogging: applyRemoteLogging, ) } func didFinishLaunching( - application _: UIApplication, + application: UIApplication, options _: [UIApplication.LaunchOptionsKey: Any]?, ) -> Bool { AppDependencyManager.shared .add(dependency: { [intentServices = self.intentServices] in intentServices }) WhereLaunch.startAmbientLogging(on: .shared) - model.onLoggedOut = { [intentServices] in await intentServices.clear() } + model.compatibility + .onBlocked = { [intentServices] failure in await intentServices.block(failure) } + model.onLoggedOut = { [intentServices, weak compatibility = model.compatibility] in + if case let .blocked(failure) = compatibility?.coordinator.state { + await intentServices.block(failure) + } else { await intentServices.clear() } + } + #if DEBUG + let usesCloudKit = buildEnvironment.storage( + forCloudKitValidationBuild: Self.isCloudKitValidationBuild, + ).usesCloudKit + #else + let usesCloudKit = buildEnvironment.storage.usesCloudKit + #endif + if usesCloudKit { application.registerForRemoteNotifications() } model.onThemeChanged = { [intentServices, widgetPresentationPublisher] theme in await widgetPresentationPublisher.publish(theme) guard !Task.isCancelled else { return } @@ -162,6 +190,15 @@ final class RegularApplicationRuntime: WhereApplicationRuntime { return true } + func didReceiveRemoteNotification(_ userInfo: [AnyHashable: Any]) async + -> UIBackgroundFetchResult + { + guard CKNotification(fromRemoteNotificationDictionary: userInfo)?.subscriptionID + == CloudKitDataCompatibilityTransport.subscriptionID else { return .noData } + await model.compatibility.refresh(model: model) + return .newData + } + func makeRootView() -> AnyView { #if DEBUG AnyView(RootView( diff --git a/Where/Where/Sources/WhereApplicationRuntime.swift b/Where/Where/Sources/WhereApplicationRuntime.swift index c30bd0832..429391b0f 100644 --- a/Where/Where/Sources/WhereApplicationRuntime.swift +++ b/Where/Where/Sources/WhereApplicationRuntime.swift @@ -14,5 +14,16 @@ protocol WhereApplicationRuntime: AnyObject { options: [UIApplication.LaunchOptionsKey: Any]?, ) -> Bool + func didReceiveRemoteNotification(_ userInfo: [AnyHashable: Any]) async + -> UIBackgroundFetchResult + func makeRootView() -> AnyView } + +extension WhereApplicationRuntime { + func didReceiveRemoteNotification(_: [AnyHashable: Any]) async + -> UIBackgroundFetchResult + { + .noData + } +} diff --git a/Where/WhereIntents/AGENTS.md b/Where/WhereIntents/AGENTS.md index d8479193b..b990d6d1f 100644 --- a/Where/WhereIntents/AGENTS.md +++ b/Where/WhereIntents/AGENTS.md @@ -25,7 +25,7 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature reliably discovers the phrases from there. Intent/entity types are `public` for it. - **Intents never start GPS.** `WhereServices.forIntents(sharingStoreOf:)` - wires an `IdleLocationSource`. An intent-logged manual entry records a + shares the app's exact service stack. Intent handlers never drive its recording lifecycle. An intent-logged manual entry records a "Logged with Siri" audit and no captured location. - **Resolve services through the `@Dependency`-injected `IntentServices`. Intents never open a store.** The app's `AppDelegate` owns the one instance @@ -36,6 +36,8 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature `current()` (cancellation-aware). There is deliberately no self-open fallback. A `LogDayIntent` write therefore pings the same `changes()` signal the running UI refreshes from. +- On compatibility lockout, clear the installed handoff and fail pending and new intents with the compatibility error. + Restore access only through the next verified session installation. Guard: `IntentServicesTests`. - **Inject the host App Group into `IntentServices`.** The Today intent's optional widget-snapshot fast path uses it; no package target owns an audience default. diff --git a/Where/WhereIntents/README.md b/Where/WhereIntents/README.md index d768ed232..2b232187c 100644 --- a/Where/WhereIntents/README.md +++ b/Where/WhereIntents/README.md @@ -10,8 +10,8 @@ device-local `WhereTheme` atomically through the `@Dependency`-injected `AppDependencyManager`. The launch installs a stack built with [`WhereServices.forIntents(sharingStoreOf:)`](../WhereCore/Sources/WhereServices+Intents.swift) over the same `SwiftDataStore` it opened. An intent that fires earlier waits -for that install rather than opening its own store. No GPS starts via -`WhereCore`'s `IdleLocationSource`). They do read/write through the existing +for that install rather than opening its own store. The handoff shares the app's exact services; +intent handlers never start GPS). They do read/write through the existing collaborators (`reports`, `journal`) using a Gregorian calendar (`Calendar.whereIntents`, matching the domain's aggregation so year/day math lines up). They render with [`WhereUI`](../WhereUI/) snippet views. The @@ -23,6 +23,9 @@ The host injects its audience-specific App Group through `IntentServices`. `TodayRegionsIntent` uses that group for the optional widget-snapshot fast path and logs an App Group-open failure before falling back to the store report. +A compatibility lockout releases installed services and fails pending and new intents with an actionable error. +An intent that already resolved services can finish. The next verified session installs fresh services for later intents. + ## Intents ### Query (read) diff --git a/Where/WhereIntents/Sources/IntentServices.swift b/Where/WhereIntents/Sources/IntentServices.swift index d530736ce..65bb303ef 100644 --- a/Where/WhereIntents/Sources/IntentServices.swift +++ b/Where/WhereIntents/Sources/IntentServices.swift @@ -34,7 +34,13 @@ public actor IntentServices { let theme: WhereTheme } - private var installed: Context? + private enum Availability { + case waiting + case installed(Context) + case blocked(DataCompatibilityFailure) + } + + private var availability = Availability.waiting /// Intents parked in `current()` awaiting installation, keyed so a /// cancelled waiter can remove exactly itself. @@ -54,7 +60,7 @@ public actor IntentServices { /// cached one. public func install(_ services: WhereServices, theme: WhereTheme) { let context = Context(services: services, theme: theme) - installed = context + availability = .installed(context) let parked = waiters waiters = [:] for continuation in parked.values { @@ -65,8 +71,8 @@ public actor IntentServices { /// Replace only the presentation identity while retaining the current /// store-sharing service stack. public func updateTheme(_ theme: WhereTheme) { - guard let installed else { return } - self.installed = Context(services: installed.services, theme: theme) + guard case let .installed(installed) = availability else { return } + availability = .installed(Context(services: installed.services, theme: theme)) } /// Release the installed stack, so nothing here keeps the app's store alive @@ -77,12 +83,20 @@ public actor IntentServices { /// first install — the alternative is answering from a store the app has /// abandoned, which is worse than waiting for the one it opens next. public func clear() { - installed = nil + availability = .waiting + } + + public func block(_ failure: DataCompatibilityFailure) { + availability = .blocked(failure) + let parked = waiters + waiters = [:] + for continuation in parked.values { + continuation.resume(throwing: failure) + } } - /// The installed stack, suspending until the launch installs one. Throws - /// only `CancellationError`, when the awaiting intent's task is cancelled - /// while parked. + /// The installed stack, suspending until the launch installs one. Cancellation and + /// compatibility lockout fail the wait without opening an independent store. /// /// Only the parking path is timed: the span's duration is how long an intent /// waited on the launch, which is the whole question a Siri-racing-startup @@ -94,8 +108,10 @@ public actor IntentServices { /// Resolve services and theme atomically for snippet presentation. func currentContext() async throws -> Context { - if let installed { - return installed + switch availability { + case let .installed(context): return context + case let .blocked(error): throw error + case .waiting: break } return try await WhereIntentsLog.logger.measure(.awaitServices) { try await park() @@ -115,7 +131,11 @@ public actor IntentServices { if Task.isCancelled { continuation.resume(throwing: CancellationError()) } else { - waiters[id] = continuation + switch availability { + case let .installed(context): continuation.resume(returning: context) + case let .blocked(error): continuation.resume(throwing: error) + case .waiting: waiters[id] = continuation + } } } } onCancel: { diff --git a/Where/WhereIntents/Tests/IntentServicesTests.swift b/Where/WhereIntents/Tests/IntentServicesTests.swift index b0c1c5b62..8f8880d3a 100644 --- a/Where/WhereIntents/Tests/IntentServicesTests.swift +++ b/Where/WhereIntents/Tests/IntentServicesTests.swift @@ -26,6 +26,20 @@ struct IntentServicesTests { #expect(resolved.journal === stack.journal) } + @Test func lockoutFailsPendingAndNewIntentsUntilFreshInstallation() async throws { + let handoff = IntentServices(appGroupIdentifier: appGroupIdentifier) + let parked = Task { try await handoff.current() } + try await waitUntil { await handoff.waiterCount == 1 } + await handoff.block(.updateRequired(.init(rawValue: 2))) + await #expect(throws: DataCompatibilityFailure.self) { try await parked.value } + await #expect(throws: DataCompatibilityFailure.self) { try await handoff.current() } + #expect(await handoff.waiterCount == 0) + let replacement = try makeStack() + await handoff.install(replacement, theme: .standard) + let current = try await handoff.current() + #expect(current.journal === replacement.journal) + } + @Test func currentParksUntilAStackIsInstalled() async throws { let handoff = IntentServices(appGroupIdentifier: appGroupIdentifier) let parked = Task { try await handoff.current() } diff --git a/Where/WhereShareExtension/AGENTS.md b/Where/WhereShareExtension/AGENTS.md index 324d2c112..817ff874a 100644 --- a/Where/WhereShareExtension/AGENTS.md +++ b/Where/WhereShareExtension/AGENTS.md @@ -24,6 +24,11 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature ## Invariants +- Before a production-cloud store opens locally, require the app's verified receipt for this exact data version. + Missing, unreadable, blocked, or mismatched receipts show Open Where and must not open history. + Keep the receipt read local. Development local-only storage bypasses this gate. + Guard: `DataCompatibilityReceiptTests` in WhereCore. + - **Writes directly through `SwiftDataStore.perform { write(evidence:blob:) }`, not `WhereServices`/`DayJournal`.** A short-lived share process must not spin up the GPS ingestor, notifiers, or widget publisher; the store commit's diff --git a/Where/WhereShareExtension/README.md b/Where/WhereShareExtension/README.md index f768807cb..e9799c562 100644 --- a/Where/WhereShareExtension/README.md +++ b/Where/WhereShareExtension/README.md @@ -8,6 +8,10 @@ The attachment bytes and metadata are written straight into the shared App Group ## How it works +Before opening shared CloudKit-backed history locally, Save checks the app's compatibility receipt. +The receipt must grant access to this exact data version. Otherwise, the extension asks the user to open Where and saves nothing. +Local-only Development builds bypass this check. The extension never verifies with CloudKit itself. + ``` Host app Share sheet └─▶ ShareViewController (principal class) diff --git a/Where/WhereShareExtension/Resources/Localizable.xcstrings b/Where/WhereShareExtension/Resources/Localizable.xcstrings index 553e0edac..2e1852864 100644 --- a/Where/WhereShareExtension/Resources/Localizable.xcstrings +++ b/Where/WhereShareExtension/Resources/Localizable.xcstrings @@ -61,6 +61,17 @@ } } }, + "share.compatibility.openWhere" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Where to verify or update your data before saving this item, then share it again." + } + } + } + }, "share.form.date" : { "comment" : "Date picker label for when the evidence was captured.", "extractionState" : "manual", diff --git a/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift b/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift index af5e0711d..e984f7663 100644 --- a/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift +++ b/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift @@ -42,15 +42,18 @@ final class ShareEvidenceModel { /// Storage the extension opens; injectable so a future test can point it at /// an in-memory store instead of the shared container. private let storage: SwiftDataStore.Storage + private let compatibilityReceipts: (any DataCompatibilityReceiptStoring)? private static let logger = WhereLog.root(ShareExtensionLog.self) init( items: [NSExtensionItem], storage: SwiftDataStore.Storage, + compatibilityReceipts: (any DataCompatibilityReceiptStoring)?, now: @Sendable () -> Date = { Date() }, ) { self.items = items self.storage = storage + self.compatibilityReceipts = compatibilityReceipts capturedAt = now() } @@ -91,6 +94,17 @@ final class ShareEvidenceModel { /// the write error. func save() async -> Bool { phase = .saving + if let compatibilityReceipts { + do { + guard let receipt = try compatibilityReceipts.load() + else { throw DataCompatibilityFailure.recordingDeviceRequired } + try receipt.requireAccess(version: .current) + } catch { + phase = .failed(String(localized: .shareCompatibilityOpenWhere)) + Self.logger.saveFailed(error: .restricted(.errorDetails, error)) + return false + } + } let pending = buildPendingEvidence() do { let store = try SwiftDataStore.make(storage: storage) diff --git a/Where/WhereShareExtension/Sources/ShareEvidenceView.swift b/Where/WhereShareExtension/Sources/ShareEvidenceView.swift index aaf85f48b..deb23a8b5 100644 --- a/Where/WhereShareExtension/Sources/ShareEvidenceView.swift +++ b/Where/WhereShareExtension/Sources/ShareEvidenceView.swift @@ -127,7 +127,7 @@ struct ShareEvidenceView: View { #if DEBUG #Preview { ShareEvidenceView( - model: ShareEvidenceModel(items: [], storage: .inMemory), + model: ShareEvidenceModel(items: [], storage: .inMemory, compatibilityReceipts: nil), onSave: {}, onCancel: {}, ) diff --git a/Where/WhereShareExtension/Sources/ShareViewController.swift b/Where/WhereShareExtension/Sources/ShareViewController.swift index 79d3d9ada..c1461838d 100644 --- a/Where/WhereShareExtension/Sources/ShareViewController.swift +++ b/Where/WhereShareExtension/Sources/ShareViewController.swift @@ -25,6 +25,11 @@ final class ShareViewController: UIViewController { storage: .localOnly( appGroupIdentifier: buildEnvironment.appGroupIdentifier, ), + compatibilityReceipts: buildEnvironment.requiresCompatibility + ? + AppGroupDataCompatibilityReceipt(appGroupIdentifier: buildEnvironment + .appGroupIdentifier) + : nil, ) let root = ShareEvidenceView( model: model, diff --git a/Where/WhereShareExtension/Sources/WhereShareBuildEnvironment.swift b/Where/WhereShareExtension/Sources/WhereShareBuildEnvironment.swift index 431e85d56..6e704cd58 100644 --- a/Where/WhereShareExtension/Sources/WhereShareBuildEnvironment.swift +++ b/Where/WhereShareExtension/Sources/WhereShareBuildEnvironment.swift @@ -4,6 +4,14 @@ import Foundation struct WhereShareBuildEnvironment { let appGroupIdentifier: String + var requiresCompatibility: Bool { + #if WHERE_DEVELOPMENT && !WHERE_CLOUDKIT_VALIDATION + false + #else + true + #endif + } + static func current( infoDictionary: [String: Any] = Bundle.main.infoDictionary ?? [:], ) -> WhereShareBuildEnvironment { diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 2e878a22c..22ad030b9 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -13,6 +13,11 @@ Layering, localization, preview, and testing conventions live in the feature ## Scope & dependencies +- Keep compatibility monitoring outside disposable scopes. Gate discovery, import recovery, and scope creation before any store open. + Mask blocked history before teardown. Stop GPS before waiting for launch work or output cleanup. + Own onboarding completion as one process-model task and drain it during compatibility retirement. + Guards: `WhereCompatibilityModelTests`, `WhereLaunchTests`, `OnboardingImportRecoveryModelTests`. + - Keep InstallerKit’s `UpdateModel` process-owned and inject it at `RootView`. Poll only from a foreground, non-demo app surface. Keep the last successful result visible on failure. Keep App Updates first in Settings for eligible builds, diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 08a20bbed..fce3441c0 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -54,6 +54,13 @@ These indicators have no separate action and remain visible after correction dis ### App shell & view models +- **`WhereCompatibilityModel` / `DataCompatibilityView`** — process-owned monitoring and a history-free lockout screen. + Verification precedes onboarding discovery, import recovery, and scope construction, including headless launches. + Foreground entry, a 60-second foreground poll, CloudKit pushes, remote imports, and Retry refresh the decision. + A lockout hides history immediately, stops recording, and tears down the old scope through the launch runner. + Recovery passes the compatibility gate and creates one fresh scope. The installation's consent and onboarding state remain intact. + New installations can choose recording before opening history. Established viewers cannot enable recording from the lockout screen. + Configured Development builds keep **App Updates** first in Settings, including local and current builds. Local builds can browse remote builds without an automatic update badge. **Other Builds** opens the shared Main/PR browser and retained build history. @@ -145,8 +152,7 @@ Demo mode hides this surface and performs no update checks. plan through `.measured()`, so each run is one Periscope span named after the step (`step(resolve-scope)`) that warns while it overruns its budget — the launch's cost breaks down per step instead of arriving as one slow - splash. (The onboarding gate is the one unmeasured node: it parks on the - user.) + splash. The onboarding and compatibility gates are unmeasured while they wait for recovery or user input. - **`WhereScope`** — what the app is logged in *to*: one open store's `WhereServices`, the `WherePreferences` driving it, and the durable log store they record into, created whole and never reconfigured. `WhereModel` owns @@ -261,7 +267,7 @@ journal intent. there is no session behind it: a paged intro, then picking up to five primary US regions (map or searchable list) and giving each a look, then verifying this installation's automatic-recording - choice. The final page opens the real store in a dormant state to inspect recent synced advisory + choice. After compatibility verification, the final page opens the real store in a dormant state to inspect recent synced advisory status before any services, App Intents, or GPS are active. A phone recommends On only when no other installation recently reported recording. Tablets, other devices, and explicit rejoins recommend Off. Only an enabled confirmation requests location permission. A restored device can diff --git a/Where/WhereUI/SnapshotTests/DataCompatibilityViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/DataCompatibilityViewSnapshotTests.swift new file mode 100644 index 000000000..36ba94245 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/DataCompatibilityViewSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct DataCompatibilityViewSnapshotTests { + @Test func dataCompatibility() async { + await assertSnapshots(of: DataCompatibilityView.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.Permission_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.Permission_iPhone.png new file mode 100644 index 000000000..e918e4121 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.Permission_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:273709f34f8845ebcd78257bf299cd2eac6d9561a8aac2779f46fba26ee814fb +size 236574 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.Permission_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.Permission_iPhone_dark.png new file mode 100644 index 000000000..f4e1f502b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.Permission_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4af156103039e34a7735e2ea039f85a50d45be302371d51de4d5764e180e4b83 +size 239191 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.RecordingDevice_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.RecordingDevice_iPhone.png new file mode 100644 index 000000000..7fc0ecfed --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.RecordingDevice_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5db09a2dcaa352e874fb594c515411d84cc56617590c6e277248da20f646912b +size 226559 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.RecordingDevice_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.RecordingDevice_iPhone_dark.png new file mode 100644 index 000000000..cf613e680 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.RecordingDevice_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1ea306328c4ea7c0fd6c651cc533b19cd733527006cd4796c56bf6ef8e99fcf1 +size 230935 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPad.png new file mode 100644 index 000000000..fbb4831a5 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f87cab361a3c637f00146b5aa6c2386a8ee53ddb47f2835087e6b3dad905fa3b +size 426839 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPad_accessibility.png new file mode 100644 index 000000000..057430588 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ba43ae11bc8087f2accd816f2a12cb4ea9765da3c047ca06398cebe658f1ea18 +size 635276 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPad_dark.png new file mode 100644 index 000000000..ce285c57e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ade13ba4a29a2c31c23b4ac1da0e5c1c21c09b27d2ec69ff830b686686819e5 +size 429854 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPhone.png new file mode 100644 index 000000000..fd99331be --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5d699d08e22235db92877f2db581ab8f7b024f934c37e0518f59141c103b431 +size 213102 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPhone_accessibility.png new file mode 100644 index 000000000..352f2bc85 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a799f55b68032bb9b47d5d07e9ccd62ee046f6bcae8e749a74333a8fdb86724 +size 392229 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPhone_dark.png new file mode 100644 index 000000000..0ebd9c7e8 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.UpdateRequired_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:64b326de707559a8019ff685a04f9bf0b507d6110b588176dffac6e798949153 +size 216428 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.Verification_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.Verification_iPhone.png new file mode 100644 index 000000000..8952263ec --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.Verification_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2bb937e8a251e63c39b4dbcff4907c9eaf206bbc60ebe35707f5e311d7fa5f39 +size 225809 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.Verification_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.Verification_iPhone_dark.png new file mode 100644 index 000000000..543155732 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/dataCompatibility.Verification_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c7771f34fe1d6b0e6e80a49f586124d049b9d35388ef5b02877ecb56bcecdd05 +size 230259 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/other/dataCompatibility.UpdateRequired_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/other/dataCompatibility.UpdateRequired_iPad_ax5.png new file mode 100644 index 000000000..a34398efe --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/other/dataCompatibility.UpdateRequired_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c318e350018140066479ca8f2ef5c77cf5ddafdb554dac8806390e8266e25614 +size 589688 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/other/dataCompatibility.UpdateRequired_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/other/dataCompatibility.UpdateRequired_iPad_contrast.png new file mode 100644 index 000000000..25ff297d7 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/other/dataCompatibility.UpdateRequired_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1918a084d512f952c47157aebc75149a73424cd11720e3c76a72bd529dcdec39 +size 429161 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/other/dataCompatibility.UpdateRequired_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/other/dataCompatibility.UpdateRequired_iPhone_ax5.png new file mode 100644 index 000000000..31f802dfb --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/other/dataCompatibility.UpdateRequired_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c7d4d6421eb5763f3604a1912288f6e70e7e7c9f1578e8a12158e7d024eefa6 +size 428063 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/other/dataCompatibility.UpdateRequired_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/other/dataCompatibility.UpdateRequired_iPhone_contrast.png new file mode 100644 index 000000000..1d20630ec --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataCompatibilityViewSnapshotTests/themes/other/dataCompatibility.UpdateRequired_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd9fe55a8eebc3c5eea7a12fc5415f66b8eb8f25ca2ac19dc4505c326ff80582 +size 215582 diff --git a/Where/WhereUI/Sources/Compatibility/DataCompatibilityView.swift b/Where/WhereUI/Sources/Compatibility/DataCompatibilityView.swift new file mode 100644 index 000000000..a819ec492 --- /dev/null +++ b/Where/WhereUI/Sources/Compatibility/DataCompatibilityView.swift @@ -0,0 +1,127 @@ +import SFSafeSymbols +import SnapshotKit +import SwiftUI +import WhereCore + +/// History-free recovery shared by cold launch and live session retirement. +struct DataCompatibilityView: View { + @Environment(\.stylesheet) private var stylesheet + @Environment(\.openURL) private var openURL + @State private var isRetrying = false + @AccessibilityFocusState private var titleFocused: Bool + let state: DataCompatibilityCoordinator.State + let retry: @MainActor () async -> Void + + private var title: LocalizedStringResource { + switch state { + case .checking, .allowed, .offline: .compatibilityCheckingTitle + case .blocked(.updateRequired): .compatibilityUpdateTitle + case .blocked(.recordingDeviceRequired): .compatibilityRecordingTitle + case .blocked(.locationPermissionRequired): .compatibilityPermissionTitle + case .blocked(.verificationRequired): .compatibilityVerificationTitle + } + } + + private var message: LocalizedStringResource { + switch state { + case .checking, .allowed, .offline: .compatibilityCheckingMessage + case .blocked(.updateRequired): .compatibilityUpdateMessage + case .blocked(.recordingDeviceRequired): .compatibilityRecordingMessage + case .blocked(.locationPermissionRequired): .compatibilityPermissionMessage + case .blocked(.verificationRequired): .compatibilityVerificationMessage + } + } + + var body: some View { + GeometryReader { geometry in + ScrollView { + VStack(spacing: stylesheet.spacing.large) { + Image(systemSymbol: .lockIcloud) + .font(.largeTitle) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(title) + .font(.title.bold()) + .accessibilityAddTraits(.isHeader) + .accessibilityFocused($titleFocused) + Text(message).foregroundStyle(.secondary) + if case .checking = state { + ProgressView() + } else { + Button(String(localized: .compatibilityRetry)) { + isRetrying = true + Task { + await retry() + isRetrying = false + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isRetrying) + if case .blocked(.locationPermissionRequired) = state { + Button(String(localized: .compatibilityOpenSettings)) { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) + } + } + } + } + } + .multilineTextAlignment(.center) + .padding(stylesheet.spacing.xxxLarge) + .frame(maxWidth: .infinity, minHeight: geometry.size.height) + } + .scrollBounceBehavior(.basedOnSize) + .background(.background) + } + .task { titleFocused = true } + } +} + +#if DEBUG + extension DataCompatibilityView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + whereThemeSnapshot( + name: "UpdateRequired", + configurations: .fullContentScreenRepresentative, + measurementReadiness: .immediate, + ) { + Self(state: .blocked(.updateRequired(.init(rawValue: 2))), retry: {}) + } + whereThemeSnapshot( + name: "RecordingDevice", + configurations: .fullContentPhoneLightDark, + measurementReadiness: .immediate, + ) { + Self(state: .blocked(.recordingDeviceRequired), retry: {}) + } + whereThemeSnapshot( + name: "Permission", + configurations: .fullContentPhoneLightDark, + measurementReadiness: .immediate, + ) { + Self(state: .blocked(.locationPermissionRequired), retry: {}) + } + whereThemeSnapshot( + name: "Verification", + configurations: .fullContentPhoneLightDark, + measurementReadiness: .immediate, + ) { + Self( + state: .blocked(.verificationRequired(URLError(.notConnectedToInternet))), + retry: {}, + ) + } + } + } + + extension DataCompatibilityView: WhereFlyoverProviding { + static let flyoverData = WhereFlyoverData.snapshots( + Self.self, + title: "Data Compatibility", + navigationContainer: .none, + ) + } + + #Preview { DataCompatibilityView.snapshotPreviews } +#endif diff --git a/Where/WhereUI/Sources/Compatibility/WhereCompatibilityModel.swift b/Where/WhereUI/Sources/Compatibility/WhereCompatibilityModel.swift new file mode 100644 index 000000000..7ac746135 --- /dev/null +++ b/Where/WhereUI/Sources/Compatibility/WhereCompatibilityModel.swift @@ -0,0 +1,129 @@ +import Foundation +import LifecycleKit +import Observation +import WhereCore + +/// Keeps compatibility alive outside disposable data scopes. Recovery uses the existing launch +/// gate; shutdown uses its serialized teardown, never a second store-owning runtime. +@MainActor +@Observable +public final class WhereCompatibilityModel { + public let coordinator: DataCompatibilityCoordinator + @ObservationIgnored public var onBlocked: @MainActor (DataCompatibilityFailure) async + -> Void = { _ in } + @ObservationIgnored private var foregroundTask: Task? + @ObservationIgnored private var remoteTask: Task? + @ObservationIgnored private var retirement: Task? + @ObservationIgnored private var wasAllowed = false + @ObservationIgnored private var waitForNextPoll: @Sendable () async throws -> Void = { + try await Task.sleep(for: .seconds(60)) + } + + public init(coordinator: DataCompatibilityCoordinator) { + self.coordinator = coordinator + } + + isolated deinit { + foregroundTask?.cancel() + remoteTask?.cancel() + retirement?.cancel() + } + + func bind(model: WhereModel, launcher: LifecycleRunner) { + wasAllowed = coordinator.state.allowsStore + coordinator.onStateChange = { [weak self, weak model, weak launcher] in + guard let self, let model, let launcher, !model.bypassesCompatibility else { return } + let previouslyAllowed = wasAllowed + wasAllowed = coordinator.state.allowsStore + if case let .blocked(failure) = coordinator.state { + Task { [weak self] in + guard let self, !coordinator.state.allowsStore else { return } + await onBlocked(failure) + if !previouslyAllowed, !coordinator.state.allowsStore { + await model.stopRecordingForCompatibility() + } + } + if previouslyAllowed, retirement == nil { + stopScopeObservation() + retirement = Task { [weak self, weak model, weak launcher] in + guard let model, let launcher else { return } + await model.stopRecordingForCompatibility() + await launcher.teardown( + WhereLaunch.compatibilityTeardown(for: model), + input: (), + ) + self?.retirement = nil + } + } + } + if !isBlocking(model), + launcher.phase.isAwaitingGate(LaunchStepID.compatibility) + { + launcher.phase.gateHandle?.complete() + } + } + } + + func isBlocking(_ model: WhereModel) -> Bool { + guard !model.bypassesCompatibility else { return false } + // A new installation can choose recording without opening history. Discovery and Finish + // still require admission; an established viewer has no role-change escape hatch. + if !model.hasConfirmedRecordingChoice || model.isCompletingOnboarding, + case .blocked(.recordingDeviceRequired) = coordinator.state { return false } + return !coordinator.state.allowsStore + } + + public func refresh(model: WhereModel) async { + guard !model.bypassesCompatibility else { return } + await coordinator.refresh { [weak model] in + guard let model else { throw CancellationError() } + let context = model.installationRecordingContext + guard context.automaticRecordingEnabled == true else { return .disabled } + guard await model.currentLocationAuthorization().allowsBackgroundTracking else { + return .permissionRequired + } + return .recording(context.currentDevice.id) + } + } + + func setForeground(_ foreground: Bool, model: WhereModel) { + foregroundTask?.cancel() + foregroundTask = nil + guard foreground else { return } + foregroundTask = Task { [weak self, weak model] in + while !Task.isCancelled { + if let self, let model { await refresh(model: model) } + else { return } + guard let wait = self?.waitForNextPoll else { return } + do { try await wait() } + catch is CancellationError { return } + catch { assertionFailure("Unexpected clock failure: \(error)"); return } + } + } + } + + func observeRemoteChanges(services: WhereServices, model: WhereModel) { + stopScopeObservation() + let changes = services.remoteDataChangeUpdates() + remoteTask = Task { [weak self, weak model] in + for await _ in changes { + guard !Task.isCancelled else { break } + if let self, let model { await refresh(model: model) } + else { break } + } + } + } + + func stopScopeObservation() { + remoteTask?.cancel() + remoteTask = nil + } + + #if DEBUG + @_spi(Testing) public func setPollWait( + _ wait: @escaping @Sendable () async throws -> Void, + ) { + waitForNextPoll = wait + } + #endif +} diff --git a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift index cdb86dc58..908f62970 100644 --- a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift +++ b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift @@ -62,6 +62,7 @@ [ LaunchSplashView.flyoverData, WhereLifecycleFailureView.flyoverData, + DataCompatibilityView.flyoverData, OnboardingView.flyoverData, RegionPickerView.flyoverData, RegionCustomizeView.flyoverData, diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index 39ab689ad..b85dfce12 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -16,6 +16,8 @@ public enum LaunchStepID: String, Sendable { /// opened. Applies to every launch reason — a headless launch parks here /// rather than opening the user's store unseen. case onboarding + case compatibility + case suspendCompatibility = "suspend-compatibility" /// Resolve the scope the launch runs against, opening the user's real /// store if they onboarded on an earlier launch — the trunk's logged-in /// scope. The splash's slow-launch caption most often shows here. @@ -134,6 +136,7 @@ public enum WhereLaunch { // records them on its observable `detachedFailures`, which nothing // renders — without this a throwing detached step would fail with no // trace in logs. + model.compatibility.bind(model: model, launcher: runner) DetachedFailureReporter.observe(runner) return runner } @@ -164,6 +167,7 @@ public enum WhereLaunch { onServicesReady: @escaping @MainActor (WhereServices) async -> Void = { _ in }, ) -> LaunchPlan { LaunchPlan(ActivateLaunchDemoStep(model: model).measured()) + .gate(CompatibilityGate(model: model)) .gate(OnboardingGate(model: model)) .then(ResolveScopeStep(model: model).measured()) .then(StartSessionStep(model: model, onServicesReady: onServicesReady).measured()) @@ -205,6 +209,12 @@ public enum WhereLaunch { LaunchPlan(ExitDemoStep(model: model).measured()) } + public static func compatibilityTeardown(for model: WhereModel) + -> LaunchPlan + { + LaunchPlan(SuspendCompatibilityStep(model: model).measured()) + } + public static func rejoinPlan(for model: WhereModel) -> LaunchPlan { @@ -226,6 +236,10 @@ public protocol WhereScopeAssembling { /// Install the location manager without touching the store. Idempotent, /// and cheap enough to run on the synchronous launch path. func prepareLocation() + func currentLocationAuthorization() async -> LocationAuthorizationStatus + func requestLocationPermission() async throws + func stopLocation() async + func shutdown() async /// Open the store and assemble the services over it. The app process's /// **one** store open. @@ -242,6 +256,18 @@ public protocol WhereScopeAssembling { func makeLogStore() async throws -> PeriscopeStore? } +extension WhereScopeAssembling { + public func currentLocationAuthorization() async + -> LocationAuthorizationStatus + { + .notDetermined + } + + public func requestLocationPermission() async throws {} + public func stopLocation() async {} + public func shutdown() async {} +} + /// Owns the assembly of the user's real world, so `WhereScope` and /// `WhereModel` consume finished pieces rather than wiring up persistence and /// CoreLocation themselves. @@ -262,17 +288,22 @@ public final class WhereBootstrap: WhereScopeAssembling { private let locationOutbox: any LocationOutbox private var locationSource: CoreLocationSource? private var preparedStore: SwiftDataStore? + private var storeOpening: Task? + private var isShutDown = false + private let compatibility: DataCompatibilityCoordinator public init( installationContextStore: any InstallationRecordingContextStoring, storeStorage: SwiftDataStore.Storage, widgetRefresher: any WidgetTimelineRefreshing, locationOutbox: any LocationOutbox, + compatibility: DataCompatibilityCoordinator, ) { self.installationContextStore = installationContextStore self.storeStorage = storeStorage self.widgetRefresher = widgetRefresher self.locationOutbox = locationOutbox + self.compatibility = compatibility } /// Install the `CLLocationManager` + delegate right away, without touching @@ -300,7 +331,7 @@ public final class WhereBootstrap: WhereScopeAssembling { /// anywhere. public func makeServices() async throws -> WhereServices { let source = locationSource ?? CoreLocationSource() - locationSource = nil + locationSource = source do { let installationContext = try installationContextStore.resolve() precondition( @@ -344,15 +375,55 @@ public final class WhereBootstrap: WhereScopeAssembling { } private func prepareStore() async throws -> SwiftDataStore { + try compatibility.requireAccess() + guard !isShutDown else { throw CancellationError() } if let preparedStore { return preparedStore } + if let storeOpening { + let store = try await storeOpening.value + try compatibility.requireAccess() + guard !isShutDown else { throw CancellationError() } + return store + } let storeStorage = storeStorage - let store = try await Task.detached(priority: .userInitiated) { + let opening = Task.detached(priority: .userInitiated) { try SwiftDataStore.make(storage: storeStorage) - }.value + } + storeOpening = opening + defer { storeOpening = nil } + let store = try await opening.value + try compatibility.requireAccess() + guard !isShutDown else { throw CancellationError() } preparedStore = store return store } + public func currentLocationAuthorization() async -> LocationAuthorizationStatus { + prepareLocation() + guard let locationSource else { return .notDetermined } + return await locationSource.currentAuthorization() + } + + public func requestLocationPermission() async throws { + prepareLocation() + try await locationSource?.requestPermission() + } + + public func stopLocation() async { + await locationSource?.stop() + } + + public func shutdown() async { + isShutDown = true + if let storeOpening { + // Opening cannot be interrupted inside SwiftData; discard it after it returns. + do { _ = try await storeOpening.value } + catch { Self.logger.servicesAssemblyFailed(error: .restricted(.errorDetails, error)) } + } + preparedStore = nil + await locationSource?.stop() + locationSource = nil + } + /// Open the app's durable log store: `Periscope.store` on disk, plus this /// launch's crash journal beside it. Opened per scope rather than per /// process, because what a session persists depends on which world it is diff --git a/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift b/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift index bb49b1e21..8e883fa3b 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift @@ -36,7 +36,7 @@ struct ActivateLaunchDemoStep: BudgetedLaunchStep { /// First-run onboarding and this installation's recording choice. /// Placed after the side-effect-free demo preflight and before every step that -/// builds the user's world: no store is opened, no CloudKit is contacted, and no +/// builds the user's world: no history store is opened and no /// session exists behind this while the user has not chosen. /// /// Unlike most gates it applies to **all** launch reasons rather than the @@ -302,3 +302,25 @@ struct ResetPreferencesStep: BudgetedLaunchStep { } } } + +/// Compatibility is recoverable, unlike a thrown launch step. It also parks headless launches. +struct CompatibilityGate: LifecycleGate { + let model: WhereModel + let id = LaunchStepID.compatibility + let modes: LifecycleModeSet = .all + + func isNeeded(_: Void) async -> Bool { + await model.compatibility.refresh(model: model) + return model.compatibility.isBlocking(model) + } +} + +/// Void input prevents the runner from retaining the old session while its new gate is parked. +struct SuspendCompatibilityStep: BudgetedLaunchStep { + let model: WhereModel + let id = LaunchStepID.suspendCompatibility + let budget: Duration = .seconds(3) + func run(_: Void, _: LifecycleStepContext) async throws { + await model.shutdownForCompatibility() + } +} diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index 316c31ea2..45b9992eb 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -17,12 +17,22 @@ import PeriscopeCore /// /// **Nothing is opened until the user asks for it.** The launch parks on the /// onboarding gate before any scope exists, so an install the user never -/// finishes onboarding creates no store file and contacts no CloudKit; the +/// finishes onboarding creates no store file. Independent compatibility metadata can use CloudKit; +/// the /// real scope is built the moment they choose to use the app for real (see /// ``resolveScope()``). @MainActor @Observable public final class WhereModel { + public let compatibility: WhereCompatibilityModel + private var scopeEpoch: UInt64 = 0 + private var scopeOpening: Task? + private var onboardingTask: Task? + + var isCompletingOnboarding: Bool { + onboardingTask != nil + } + /// Observable bring-up state for the active scope's durable log store. /// /// The developer surface must not infer a failed asynchronous open from a @@ -95,6 +105,14 @@ public final class WhereModel { private var launchDemoState = LaunchDemoState.inactive + var bypassesCompatibility: Bool { + if isInDemoMode { return true } + switch launchDemoState { + case .inactive: return false + case .pending, .building: return true + } + } + /// Whether the cold-launch splash is currently building a requested demo. var isBuildingLaunchDemo: Bool { if case .building = launchDemoState { return true } @@ -225,6 +243,8 @@ public final class WhereModel { now: now(), ) } + await compatibility.refresh(model: self) + try compatibility.coordinator.requireAccess() return try await RecordingOnboardingRecommendation( for: context.currentDevice, devices: bootstrap.discoverRecordingDevices(), @@ -355,12 +375,16 @@ public final class WhereModel { any InstallationRecordingContextStoring, ) -> any WhereScopeAssembling, logSystem: Periscope, + compatibility: WhereCompatibilityModel? = nil, effectiveDiagnosticReportingConfiguration: DiagnosticReportingConfiguration? = nil, applyRemoteLogging: @escaping DiagnosticReportingSettingsModel.ApplyRemoteLogging = { _, _ in }, now: @escaping @Sendable () -> Date = { Date() }, ) { + self + .compatibility = compatibility ?? + WhereCompatibilityModel(coordinator: DataCompatibilityCoordinator()) self.preferences = preferences diagnosticReporting = DiagnosticReportingSettingsModel( preferences: preferences, @@ -396,6 +420,7 @@ public final class WhereModel { selectedYear: Int = WhereModel.currentYear, preferences: WherePreferences, logSystem: Periscope, + compatibility: WhereCompatibilityModel? = nil, effectiveDiagnosticReportingConfiguration: DiagnosticReportingConfiguration? = nil, applyRemoteLogging: @escaping DiagnosticReportingSettingsModel.ApplyRemoteLogging = { _, _ in @@ -411,6 +436,9 @@ public final class WhereModel { logSystem: logSystem, ) scopeState = .real(scope) + self + .compatibility = compatibility ?? + WhereCompatibilityModel(coordinator: DataCompatibilityCoordinator()) self.preferences = preferences diagnosticReporting = DiagnosticReportingSettingsModel( preferences: preferences, @@ -477,22 +505,49 @@ public final class WhereModel { /// Throws if the store can't be opened, leaving the model logged out so a /// later attempt can try again. public func resolveScope() async throws -> WhereScope { + if !isInDemoMode { + await compatibility.refresh(model: self) + try compatibility.coordinator.requireAccess() + } + let openingEpoch = scopeEpoch switch scopeState { case let .real(scope), let .demo(scope): return scope case let .loggedOut(bootstrap): - let scope = try await WhereScope.real( - bootstrap: bootstrap, - preferences: preferences, - logSystem: logSystem, - onLogStoreStateChange: { [weak self] scope, state in - guard self?.activeScope === scope else { return } - self?.logStoreState = state - }, - ) + let opening: Task + if let scopeOpening { + opening = scopeOpening + } else { + let task = Task { @MainActor [preferences, logSystem, weak self] in + try await WhereScope.real( + bootstrap: bootstrap, + preferences: preferences, + logSystem: logSystem, + onLogStoreStateChange: { [weak self] scope, state in + guard self?.activeScope === scope else { return } + self?.logStoreState = state + }, + ) + } + scopeOpening = task + opening = task + } + defer { if openingEpoch == scopeEpoch { scopeOpening = nil } } + let scope = try await opening.value // Replacing the state releases the bootstrap: it has handed // over its location source and opened its store, and the next // login gets a fresh one. + guard openingEpoch == scopeEpoch else { + await scope.services.shutdown() + await scope.stopLogRouting() + throw CancellationError() + } + do { try compatibility.coordinator.requireAccess() } + catch { + await scope.services.shutdown() + await scope.stopLogRouting() + throw error + } scopeState = .real(scope) logStoreState = scope.logStoreState Self.logger.openedRealScope() @@ -597,6 +652,12 @@ public final class WhereModel { now: scope.sessionNow ?? now, ) self.session = session + if scope.kind == .real { + compatibility.observeRemoteChanges(services: scope.services, model: self) + session.onDeviceRemoved = { [weak compatibility] deviceID in + compatibility?.coordinator.recordRemoval(of: deviceID) + } + } Self.logger.startedSession( year: .restricted(.domainValue, initialSelectedYear), ) @@ -607,6 +668,49 @@ public final class WhereModel { /// teardown after `eraseAllData()` and when onboarding abandons a failed /// restore attempt: the next login builds a fresh scope over a newly-opened /// store and the installation context current at that attempt. + func currentLocationAuthorization() async -> LocationAuthorizationStatus { + if let session { return await session.services.ingestor.authorizationStatus() } + if case let .loggedOut(bootstrap) = scopeState { + return await bootstrap.currentLocationAuthorization() + } + return .notDetermined + } + + func requestLocationPermissionBeforeOpening() async throws { + if case let .loggedOut(bootstrap) = scopeState { + try await bootstrap.requestLocationPermission() + } + } + + func shutdownForCompatibility() async { + scopeEpoch &+= 1 + let onboarding = onboardingTask + onboarding?.cancel() + if let session { await session.shutdown() } + else if let scope = activeScope { await scope.services.shutdown() } + if case let .loggedOut(bootstrap) = scopeState { await bootstrap.shutdown() } + await onboarding?.value + onboardingTask = nil + await logOut() + } + + func stopRecordingForCompatibility() async { + if let scope = activeScope { + await scope.services.recording.shutdown() + } else if case let .loggedOut(bootstrap) = scopeState { + await bootstrap.stopLocation() + } + } + + /// Own the onboarding work independently of the view that a lockout replaces. + func performOnboarding(_ operation: @escaping @MainActor () async -> Void) { + guard onboardingTask == nil else { return } + onboardingTask = Task { + await operation() + onboardingTask = nil + } + } + public func endSession() async { await logOut() Self.logger.endedSession() @@ -627,6 +731,9 @@ public final class WhereModel { /// false or unset, so the relaunch parks for the user before anything /// re-opens. The old container is long gone by the time they answer. private func logOut() async { + scopeEpoch &+= 1 + scopeOpening = nil + compatibility.stopScopeObservation() await activeScope?.stopLogRouting() session = nil scopeState = .loggedOut(bootstrap: makeBootstrap(installationContextStore)) diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index c645c217f..5d7d3766b 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -31,6 +31,20 @@ import WhereCore @MainActor @Observable public final class WhereSession { + var onDeviceRemoved: @MainActor (RecordingDeviceID) -> Void = { _ in } + + func shutdown() async { + let observers = [authorizationTask, recordingConfigurationTask, regionStyleTask] + observers.forEach { $0?.cancel() } + authorizationTask = nil + recordingConfigurationTask = nil + regionStyleTask = nil + await services.shutdown() + for observer in observers { + await observer?.value + } + } + /// A process-unique, monotonically increasing identity for this session. /// `RootView` keys `MainTabs` (and thus the scene-scoped `YearReportModel`) on /// it, so a reset that rebuilds the session forces a fresh scene. Unlike an @@ -410,6 +424,7 @@ public final class WhereSession { didRegisterRecordingDevice = false } else if case .removed = update.state { didRegisterRecordingDevice = false + onDeviceRemoved(services.recording.currentDevice.id) } } diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingFlowModel.swift b/Where/WhereUI/Sources/Onboarding/OnboardingFlowModel.swift index b3c634722..42063b351 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingFlowModel.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingFlowModel.swift @@ -106,7 +106,7 @@ final class OnboardingFlowModel { phase = .intro } isFinishing = true - Task { + model.performOnboarding { [self] in do { let context = try model.confirmInitialRecordingChoice( isEnabled: recordingEnabled, @@ -122,9 +122,20 @@ final class OnboardingFlowModel { return } + if recordingEnabled { + do { try await model.requestLocationPermissionBeforeOpening() } + catch { Self.logger.locationPermissionDenied() } + } + guard !Task.isCancelled else { return } let scope: WhereScope do { scope = try await model.resolveScope() + } catch is DataCompatibilityFailure { + isFinishing = false + return + } catch is CancellationError { + isFinishing = false + return } catch { Self.logger.scopeCreationFailed( error: .restricted(.errorDetails, error), @@ -136,10 +147,12 @@ final class OnboardingFlowModel { if let readyImport { guard await importBackup(readyImport, into: scope, using: model) else { return } } + guard !Task.isCancelled else { return } do { try await configureRecording(in: scope) } catch { + guard !Task.isCancelled else { return } Self.logger.recordingConfigurationFailed( error: .restricted(.errorDetails, error), ) @@ -163,6 +176,7 @@ final class OnboardingFlowModel { ) } } + guard !Task.isCancelled else { return } if !model.hasOnboarded { model.completeOnboarding() } @@ -276,6 +290,7 @@ final class OnboardingFlowModel { gate.fail(error) return false } catch { + guard !Task.isCancelled else { return false } restoreSelection.discardUncommittedSelection() await model.endSession() intro.activity = .failed(.init(flow: .restoreBackup, error: error)) diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingImportRecoveryModel.swift b/Where/WhereUI/Sources/Onboarding/OnboardingImportRecoveryModel.swift index 7dd158f09..6b35b5e2e 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingImportRecoveryModel.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingImportRecoveryModel.swift @@ -54,6 +54,7 @@ final class OnboardingImportRecoveryModel { endSession: () async -> Void, completeOnboarding: () -> Void, ) async -> Bool { + interruptedImportError = nil guard hasInterruptedImport else { return requiresOnboarding } @@ -73,6 +74,10 @@ final class OnboardingImportRecoveryModel { try await scope.services.backup.acknowledgeOnboardingImport() return false } + } catch is DataCompatibilityFailure { + return false + } catch is CancellationError { + return false } catch { interruptedImportError = error return false diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index 6387343a9..1ee1ac662 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -306,7 +306,10 @@ public struct OnboardingView: View { .buttonStyle(.borderedProminent) .controlSize(.large) } - .disabled(flow.isFinishing || flow.deviceDiscovery == .loading) + .disabled( + flow.isFinishing || model.isCompletingOnboarding + || flow.deviceDiscovery == .loading, + ) } .padding(.horizontal, stylesheet.spacing.xxxLarge) .padding(.bottom, stylesheet.spacing.xxxLarge) diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index f8a44eeba..d6ca72e38 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -2472,6 +2472,138 @@ } } }, + "compatibility.checking.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checking that this version of Where can use your data." + } + } + } + }, + "compatibility.checking.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checking iCloud" + } + } + } + }, + "compatibility.openSettings" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Settings" + } + } + } + }, + "compatibility.permission.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This device has recording turned on. Allow Always location access in Settings before it updates your shared data." + } + } + } + }, + "compatibility.permission.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allow Background Recording" + } + } + } + }, + "compatibility.recording.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This version of Where needs a data update. Open the latest version on a device that records your location, then try again here." + } + } + } + }, + "compatibility.recording.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update Your Recording Device" + } + } + } + }, + "compatibility.retry" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try Again" + } + } + } + }, + "compatibility.update.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A recording device has updated your data. Install a newer version of Where to open it on this device. Your data has not been erased." + } + } + } + }, + "compatibility.update.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update Where" + } + } + } + }, + "compatibility.verification.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connect to the internet and check that you are signed in to iCloud, then try again. Your data will stay closed until Where can verify its version." + } + } + } + }, + "compatibility.verification.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to Verify Your Data" + } + } + } + }, "demo.building.subtitle" : { "comment" : "Interstitial subtitle shown while demo mode's sample year is generated.", "extractionState" : "manual", diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index f0896d4bd..93231d6c3 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -127,6 +127,7 @@ public struct RootView: View { storeStorage: .inMemory, widgetRefresher: NoopWidgetTimelineRefresher(), locationOutbox: NoOpLocationOutbox(), + compatibility: DataCompatibilityCoordinator(), ) }, logSystem: .shared, @@ -153,53 +154,74 @@ public struct RootView: View { private func rootContent(stylesheet: WhereStylesheet) -> some View { ZStack { - LifecycleContainer( - launcher, - transition: stylesheet.launch.reveal.transition, - animation: stylesheet.launch.revealAnimation, - minimumSplashDuration: stylesheet.launch.minimumSplashDuration, - isPresentationVisible: isLifecyclePresentationVisible, - splash: { _ in - if model.isBuildingLaunchDemo { - LaunchSplashView(caption: .work( - title: String(localized: .demoBuildingTitle), - subtitle: String(localized: .demoBuildingSubtitle), - )) - } else { - LaunchSplashView() + Group { + if model.compatibility.isBlocking(model) { + DataCompatibilityView(state: model.compatibility.coordinator.state) { + await model.compatibility.refresh(model: model) } - }, - failure: { WhereLifecycleFailureView(failure: $0) }, - gates: { - // The gate precedes every world-building step, so there is - // no session (and no open store) behind it yet — onboarding - // builds the scope it commits regions with, through the model. - GateView(for: OnboardingGate.self) { handle, _ in - OnboardingView( - gate: handle, - installationContext: model.installationRecordingContext, - startsAtRecordingChoice: model.hasOnboarded, - initialTheme: model.theme, - ) - } - }, - ) { session in - // `.ready` carries the session the launch produced — the app - // surface cannot render without it. `MainTabs` owns the - // scene-scoped `YearReportModel` and gets a fresh one whenever - // a reset rebuilds the session. Keyed on the session's - // monotonic `id` (never reused within the process) rather than - // its address, so a rebuilt session can't collide with a freed - // one and skip the rebuild. - if session.isCurrentDeviceRemoved { - RemovedDeviceView(model: model, session: session) } else { - MainTabs( - session: session, - initialDetails: model.initialYearDetails, - selectedYear: model.initialSelectedYear, - ) - .id(session.id) + LifecycleContainer( + launcher, + transition: stylesheet.launch.reveal.transition, + animation: stylesheet.launch.revealAnimation, + minimumSplashDuration: stylesheet.launch.minimumSplashDuration, + isPresentationVisible: isLifecyclePresentationVisible, + splash: { _ in + if model.isBuildingLaunchDemo { + LaunchSplashView(caption: .work( + title: String(localized: .demoBuildingTitle), + subtitle: String(localized: .demoBuildingSubtitle), + )) + } else { + LaunchSplashView() + } + }, + failure: { WhereLifecycleFailureView(failure: $0) }, + gates: { + // The gate precedes every world-building step, so there is + // no session (and no open store) behind it yet — onboarding + // builds the scope it commits regions with, through the model. + GateView(for: CompatibilityGate.self) { handle, _ in + DataCompatibilityView(state: model.compatibility.coordinator + .state) + { + await model.compatibility.refresh(model: model) + if !model.compatibility + .isBlocking(model) { handle.complete() } + } + } + GateView(for: OnboardingGate.self) { handle, _ in + OnboardingView( + gate: handle, + installationContext: model.installationRecordingContext, + startsAtRecordingChoice: model.hasOnboarded, + initialTheme: model.theme, + ) + } + }, + ) { session in + // `.ready` carries the session the launch produced — the app + // surface cannot render without it. `MainTabs` owns the + // scene-scoped `YearReportModel` and gets a fresh one whenever + // a reset rebuilds the session. Keyed on the session's + // monotonic `id` (never reused within the process) rather than + // its address, so a rebuilt session can't collide with a freed + // one and skip the rebuild. + if model.compatibility.isBlocking(model) { + DataCompatibilityView(state: model.compatibility.coordinator.state) { + await model.compatibility.refresh(model: model) + } + } else if session.isCurrentDeviceRemoved { + RemovedDeviceView(model: model, session: session) + } else { + MainTabs( + session: session, + initialDetails: model.initialYearDetails, + selectedYear: model.initialSelectedYear, + ) + .id(session.id) + } + } } } // Extend the app content's safe area by the floating HUD's footprint so @@ -277,15 +299,19 @@ public struct RootView: View { // would leave the user staring at the empty background surface for the // entire (possibly slow) headless drive instead of the splash. .task { + model.compatibility.setForeground(scenePhase == .active, model: model) if scenePhase == .active { await launcher.enterForeground() } await launcher.run() } .onChange(of: scenePhase) { _, newPhase in + model.compatibility.setForeground(newPhase == .active, model: model) guard newPhase == .active else { return } Task { await launcher.enterForeground() + await model.compatibility.refresh(model: model) + guard !model.compatibility.isBlocking(model) else { return } await model.session?.appBecameActive() } } diff --git a/Where/WhereUI/Tests/OnboardingImportRecoveryModelTests.swift b/Where/WhereUI/Tests/OnboardingImportRecoveryModelTests.swift index 2abfd73bf..ae7ced90c 100644 --- a/Where/WhereUI/Tests/OnboardingImportRecoveryModelTests.swift +++ b/Where/WhereUI/Tests/OnboardingImportRecoveryModelTests.swift @@ -5,6 +5,36 @@ import Testing @MainActor struct OnboardingImportRecoveryModelTests { + @Test(arguments: [false, true]) + func supersededAdmissionDoesNotBecomeAnImportFailure(cancelled: Bool) async throws { + let installation = InMemoryInstallationRecordingContextStore(context: .testing) + try installation.setBackupImportRecovery(.prepared(.init( + transactionID: UUID(), + strategy: .merge, + summary: .init( + sampleCount: 0, + evidenceCount: 0, + manualDayCount: 0, + dismissedIssueCount: 0, + trackedRegionCount: 0, + recordingDeviceCount: 0, + recordingDeviceRemovalCount: 0, + ), + ))) + let model = OnboardingImportRecoveryModel(installationContextStore: installation) + _ = await model.recoverInterruptedImport( + requiresOnboarding: false, + resolveScope: { + if cancelled { throw CancellationError() } + throw DataCompatibilityFailure.updateRequired(.init(rawValue: 2)) + }, + endSession: { Issue.record("Admission failure must not abandon import recovery") }, + completeOnboarding: { Issue.record("Admission failure must not complete onboarding") }, + ) + #expect(model.takeInterruptedImportError() == nil) + #expect(installation.backupImportRecovery != nil) + } + @Test func noMarkerPreservesTheOrdinaryOnboardingDecision() async { let model = OnboardingImportRecoveryModel( installationContextStore: InMemoryInstallationRecordingContextStore( diff --git a/Where/WhereUI/Tests/Support/CompatibilityLaunchSupport.swift b/Where/WhereUI/Tests/Support/CompatibilityLaunchSupport.swift new file mode 100644 index 000000000..10c6d16a7 --- /dev/null +++ b/Where/WhereUI/Tests/Support/CompatibilityLaunchSupport.swift @@ -0,0 +1,126 @@ +import Foundation +import PeriscopeCore +@_spi(Testing) import WhereCore +@testable import WhereUI + +@MainActor +final class CompatibilityPollClock { + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + private(set) var waitCount = 0 + + init() { + (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + } + + func wait() async throws { + waitCount += 1 + for await _ in stream { + try Task.checkCancellation() + return + } + throw CancellationError() + } + + func tick() { + continuation.yield() + } +} + +actor LaunchCompatibilityTransport: DataCompatibilityTransport { + var version: DataModelVersion + init(version: Int) { + self.version = .init(rawValue: version) + } + + func setVersion(_ value: Int) { + version = .init(rawValue: value) + } + + func accountID() async throws -> DataCompatibilityAccountID { + .init(rawValue: "launch-test") + } + + func read(accountID _: DataCompatibilityAccountID) async throws -> SharedDataModelVersion? { + .init(version: version, revision: String(version.rawValue)) + } + + func advance( + accountID _: DataCompatibilityAccountID, + from _: SharedDataModelVersion?, + to version: DataModelVersion, + ) async throws -> SharedDataModelVersion { + self.version = max(self.version, version) + return .init(version: self.version, revision: String(self.version.rawValue)) + } + + nonisolated func accountChanges() -> AsyncStream { + AsyncStream { $0.finish() } + } +} + +@MainActor +final class LaunchCompatibilityReceipt: DataCompatibilityReceiptStoring { + var value: DataCompatibilityReceipt? + func load() throws -> DataCompatibilityReceipt? { + value + } + + func save(_ receipt: DataCompatibilityReceipt) throws { + value = receipt + } +} + +/// Creates services only when the real launch asks, and retains only weak store references. +@MainActor +final class CompatibilityLaunchHarness { + final class StoreReference { + weak var store: SwiftDataStore? + init(_ store: SwiftDataStore) { + self.store = store + } + } + + var opens = 0 + var discoveries = 0 + var locationStops = 0 + var stores: [StoreReference] = [] + var source = ScriptedLocationSource() + + func services(context: InstallationRecordingContext) throws -> WhereServices { + opens += 1 + let store = try SwiftDataStore.inMemory() + stores.append(StoreReference(store)) + source = ScriptedLocationSource() + return WhereServices(store: store, locationSource: source, installationContext: context) + } +} + +@MainActor +struct CompatibilityLaunchBootstrap: WhereScopeAssembling { + let harness: CompatibilityLaunchHarness + let context: any InstallationRecordingContextStoring + func prepareLocation() {} + func stopLocation() async { + harness.locationStops += 1 + } + + func currentLocationAuthorization() async -> LocationAuthorizationStatus { + .always + } + + func requestLocationPermission() async throws {} + func makeServices() async throws -> WhereServices { + try harness + .services(context: context.resolve()) + } + + func discoverRecordingDevices() async throws -> [RecordingDevice] { + harness.discoveries += 1 + return [] + } + + func makeLogStore() async throws -> PeriscopeStore? { + nil + } +} diff --git a/Where/WhereUI/Tests/WhereCompatibilityModelTests.swift b/Where/WhereUI/Tests/WhereCompatibilityModelTests.swift new file mode 100644 index 000000000..dd8e3f3f7 --- /dev/null +++ b/Where/WhereUI/Tests/WhereCompatibilityModelTests.swift @@ -0,0 +1,175 @@ +import Foundation +import LifecycleKit +@_spi(Testing) import PeriscopeCore +import TestHostSupport +import Testing +@_spi(Testing) import WhereCore +@_spi(Testing) @testable import WhereUI + +@MainActor +struct WhereCompatibilityModelTests { + @Test func foregroundPollDetectsAChangeWithoutAPush() async throws { + let harness = CompatibilityLaunchHarness() + let transport = LaunchCompatibilityTransport(version: 1) + let model = model( + currentVersion: 1, + context: .testing, + harness: harness, + transport: transport, + ) + let clock = CompatibilityPollClock() + model.compatibility.setPollWait { try await clock.wait() } + model.compatibility.setForeground(true, model: model) + defer { model.compatibility.setForeground(false, model: model) } + try await waitForAsync { clock.waitCount == 1 } + #expect(model.compatibility.coordinator.state.allowsStore) + await transport.setVersion(2) + clock.tick() + try await waitForAsync { !model.compatibility.coordinator.state.allowsStore } + #expect(harness.opens == 0) + } + + @Test func onboardingCompletionHasOneProcessOwnedTask() async throws { + let harness = CompatibilityLaunchHarness() + let model = model( + currentVersion: 1, + context: .testing, + harness: harness, + transport: LaunchCompatibilityTransport(version: 1), + ) + let clock = CompatibilityPollClock() + var starts = 0 + model.performOnboarding { + starts += 1 + do { try await clock.wait() } + catch { Issue.record(error) } + } + try await waitForAsync { clock.waitCount == 1 } + model.performOnboarding { starts += 1 } + #expect(starts == 1) + #expect(model.isCompletingOnboarding) + clock.tick() + try await waitForAsync { !model.isCompletingOnboarding } + } + + private func model( + currentVersion: Int, + context: InstallationRecordingContext, + harness: CompatibilityLaunchHarness, + transport: LaunchCompatibilityTransport, + ) -> WhereModel { + let preferences = makePreferences() + preferences.hasOnboarded = true + let compatibility = WhereCompatibilityModel(coordinator: DataCompatibilityCoordinator( + transport: transport, + receipts: LaunchCompatibilityReceipt(), + version: .init(rawValue: currentVersion), + )) + return WhereModel( + preferences: preferences, + installationContextStore: InMemoryInstallationRecordingContextStore(context: context), + makeBootstrap: { CompatibilityLaunchBootstrap(harness: harness, context: $0) }, + logSystem: .isolated(), + compatibility: compatibility, + ) + } + + @Test(arguments: [LifecycleReason.userForeground, .undetermined]) + func blockedLaunchAndDiscoveryNeverOpenHistory(reason: LifecycleReason) async throws { + let harness = CompatibilityLaunchHarness() + let transport = LaunchCompatibilityTransport(version: 2) + let model = model( + currentVersion: 1, + context: .testing, + harness: harness, + transport: transport, + ) + let launcher = WhereLaunch.makeLauncher(model: model, reason: reason) + let launch = Task { await launcher.run() } + defer { launcher.phase.gateHandle?.fail(CancellationError()); launch.cancel() } + try await waitForAsync { launcher.phase.isAwaitingGate(LaunchStepID.compatibility) } + #expect(harness.opens == 0) + await #expect(throws: DataCompatibilityFailure.self) { + try await model.discoverRecordingRecommendation(for: .testing) + } + #expect(harness.discoveries == 0) + #expect(model.session == nil) + try await waitForAsync { harness.locationStops > 0 } + } + + @Test func waitingViewerRecoversWithOneFreshScope() async throws { + let harness = CompatibilityLaunchHarness() + let transport = LaunchCompatibilityTransport(version: 1) + let model = model( + currentVersion: 2, + context: InstallationRecordingContext.testing.settingAutomaticRecordingEnabled( + false, + at: Date(), + ), + harness: harness, + transport: transport, + ) + let launcher = WhereLaunch.makeLauncher(model: model, reason: .userForeground) + let launch = Task { await launcher.run() } + try await waitForAsync { launcher.phase.isAwaitingGate(LaunchStepID.compatibility) } + #expect(harness.opens == 0) + await transport.setVersion(2) + await model.compatibility.refresh(model: model) + await launch.value + #expect(launcher.phase.isReady) + #expect(harness.opens == 1) + #expect(model.installationRecordingContext.automaticRecordingEnabled == false) + await model.session?.shutdown() + } + + @Test func runningLockoutReleasesStoreAndPreservesConsent() async throws { + let harness = CompatibilityLaunchHarness() + let transport = LaunchCompatibilityTransport(version: 1) + let model = model( + currentVersion: 1, + context: .testing, + harness: harness, + transport: transport, + ) + let launcher = WhereLaunch.makeLauncher(model: model, reason: .userForeground) + await launcher.run() + #expect(model.session?.isTracking == true) + weak var oldSession = model.session + let storeReference = try #require(harness.stores.first) + var blocked = false + model.compatibility.onBlocked = { _ in blocked = true } + await transport.setVersion(2) + await model.compatibility.refresh(model: model) + try await waitForAsync { launcher.phase.isAwaitingGate(LaunchStepID.compatibility) } + #expect(blocked) + #expect(model.session == nil) + #expect(model.hasOnboarded) + #expect(model.installationRecordingContext.automaticRecordingEnabled == true) + try await waitForAsync { oldSession == nil && storeReference.store == nil } + #expect(harness.opens == 1) + launcher.phase.gateHandle?.fail(CancellationError()) + } + + @Test func initialOffChoiceBecomesVisibleLockoutWithoutOpeningStore() async throws { + let harness = CompatibilityLaunchHarness() + let transport = LaunchCompatibilityTransport(version: 1) + let unconfirmed = InstallationRecordingContext( + currentDevice: InstallationRecordingContext.testing.currentDevice, + registeredAt: Date(), + recordingChoice: .unconfirmed, + isRejoining: false, + ) + let model = model( + currentVersion: 2, + context: unconfirmed, + harness: harness, + transport: transport, + ) + await model.compatibility.refresh(model: model) + #expect(!model.compatibility.isBlocking(model)) + try model.confirmInitialRecordingChoice(isEnabled: false) + await model.compatibility.refresh(model: model) + #expect(model.compatibility.isBlocking(model)) + #expect(harness.opens == 0) + } +} diff --git a/Where/WhereUI/Tests/WhereLaunchTests.swift b/Where/WhereUI/Tests/WhereLaunchTests.swift index d684f0b62..f4bfda2f5 100644 --- a/Where/WhereUI/Tests/WhereLaunchTests.swift +++ b/Where/WhereUI/Tests/WhereLaunchTests.swift @@ -160,6 +160,7 @@ struct WhereLaunchTests { let ids = WhereLaunch.plan(for: model).nodeIDs #expect(ids == [ .activateDemo, + .compatibility, .onboarding, .resolveScope, .startSession,