diff --git a/.bumper/RULES.md b/.bumper/RULES.md index 8ce84de00..0b6e9a472 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 cf0cebee9..9eb6f883c 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", + ) reservedRegionLayoutOwnershipRule productionStoreOpeningRule checkedConcurrencyBoundaryRule diff --git a/.bumper/Tests/WhereProjectRulesTests.swift b/.bumper/Tests/WhereProjectRulesTests.swift index 75d48d665..36d7f2c29 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 `system reserved regions use one geometry adapter`() throws { let source = "let regions = proxy.reservedRegions(kind: .division, options: .includeInactive, layoutDirectionBehavior: .fixed)" diff --git a/Where/Guidance/launch.md b/Where/Guidance/launch.md index b322458c3..aeb0daef9 100644 --- a/Where/Guidance/launch.md +++ b/Where/Guidance/launch.md @@ -2,6 +2,10 @@ ## 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 8581f6d26..80ffdb083 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 95fa402ee..d23510c72 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/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 4d3dc8166..d0ea3c005 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -29,6 +29,13 @@ Read the matching instructions before changing or reviewing that behavior, inclu ## 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`. + - Do not import SwiftUI or UIKit into WhereCore. - Use InstallerKit for local and published build identity, feed validation, and cache policy. Keep `WherePreferences.installerUpdates` device-local. `BuildInfo` exposes an 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 {} +} diff --git a/Where/WhereIntents/AGENTS.md b/Where/WhereIntents/AGENTS.md index b6457d4f1..41ef33d0e 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 fa7aed3a9..355d8c62a 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 74fcac059..4abaef45a 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -11,7 +11,7 @@ Read the matching instructions before changing or reviewing that behavior, inclu | --- | --- | | Unit tests, image snapshots, test fixtures, or coverage changes | [Test contracts](Tests/AGENTS.md) | | Settings, feature discovery, icons, updates, backup export, or diagnostic controls | [Settings contracts](Sources/Settings/AGENTS.md) | -| Launch, reset, installation sidecars, log retention, or scope models | [Launch and installation-context contracts](Sources/Launch/AGENTS.md) | +| Launch, compatibility, reset, installation sidecars, log retention, or scope models | [Launch and installation-context contracts](Sources/Launch/AGENTS.md) | | Developer tools, Inspector, demo controls, or Flyover | [Developer and Flyover contracts](Sources/Developer/AGENTS.md) | | Calendar, Your Year, Timeline, or scene report publication | [Calendar and timeline contracts](Sources/Year/AGENTS.md) | | GPS correction reviews, flight notices, or point actions | [Flight-review contracts](Sources/Resolution/AGENTS.md) | diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 0a0063a70..6e7ae9d64 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. @@ -213,8 +220,7 @@ The reusable panes also support iPad layouts. There is no HomeView target. 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 @@ -329,7 +335,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 53b84de64..22f8ef19a 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/AGENTS.md b/Where/WhereUI/Sources/Launch/AGENTS.md index ea7a28fc0..fdb86b4d8 100644 --- a/Where/WhereUI/Sources/Launch/AGENTS.md +++ b/Where/WhereUI/Sources/Launch/AGENTS.md @@ -2,6 +2,11 @@ Read the [module contract](../../AGENTS.md) and the [repository contract](../../../../AGENTS.md) first. +- 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 `FileInstallationRecordingContextStore` as the UIKit/FileManager adapter for Core's installation-context protocol. Resolve one instance at the app root. Inject it into both `WhereModel` and `WhereBootstrap`. 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 c82063c43..c8c645adb 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -2505,6 +2505,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 82d7704e5..304cc036d 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,