diff --git a/.bumper/RULES.md b/.bumper/RULES.md index 21b3d5633..b7ec56445 100644 --- a/.bumper/RULES.md +++ b/.bumper/RULES.md @@ -92,6 +92,19 @@ These use Bumper's standard `constructionOwnership` shaper. TheButtonHeist's as the analogous lower-level ownership check and retained; the standard shaper fully expresses Where's constructor facts. +## Protected installation context + +`where.installation_context_ownership` keeps sidecar construction in +`RegularApplicationRuntime`. `where.installation_context_preparation` keeps +`prepareAfterFirstUnlock()` calls there too. The runtime injects one instance +and prepares it through the shared launch plan before onboarding or store access. + +Repair a violation by using the injected context or shared launch barrier. +The rules constrain ownership, not temporal ordering. `FirstUnlockAvailabilityTests` +and the backup lifecycle model check the wait and preparation protocol. +The `.bumper/Tests` mutations reject competing construction and preparation owners. +Change these rules only when first-unlock ownership changes in `Where/Where/AGENTS.md`. + ## Gregorian calendar `where.gregorian_calendar` rejects `Calendar.current` throughout Where's diff --git a/.bumper/Sources/WhereProjectRules.swift b/.bumper/Sources/WhereProjectRules.swift index 0e047ad3b..be645d4b2 100644 --- a/.bumper/Sources/WhereProjectRules.swift +++ b/.bumper/Sources/WhereProjectRules.swift @@ -12,6 +12,12 @@ let whereProjectRules = RuleSet { allowed: .files(["Where/WhereUI/Sources/Launch/WhereLaunch.swift"]), id: "where.live_location_source_ownership", ) + Rules.constructionOwnership( + "FileInstallationRecordingContextStore", + allowed: .files(["Where/Where/Sources/RegularApplicationRuntime.swift"]), + id: "where.installation_context_ownership", + ) + installationContextPreparationRule Rules.singleNominalSpelling( suffix: "Log", owner: whereLoggingScope, @@ -26,6 +32,29 @@ let whereProjectRules = RuleSet { previewCoverageRule } +private let installationContextPreparationRule = Rules.files( + "where.installation_context_preparation", + severity: .error, + summary: "Only the regular runtime prepares the protected installation sidecar.", +) { file in + functionCalls() + .filter { match in + match.node.calledExpression.as(MemberAccessExprSyntax.self)?.declName.baseName.text + == "prepareAfterFirstUnlock" + && file.path != "Where/Where/Sources/RegularApplicationRuntime.swift" + } + .matches(in: file) + .map { match in + match.failure( + message: "Installation context preparation is outside the app's first-unlock owner.", + evidence: ViolationEvidence( + observed: "prepareAfterFirstUnlock in \(file.path.rawValue)", + expectation: "prepare the sidecar through RegularApplicationRuntime's shared launch barrier", + ), + ) + } +} + private let whereServicesConstructionScope = RuleScope .component(WhereComponent.whereCore) .union(.files(["Where/WhereUI/Sources/Preview/PreviewSupport.swift"])) diff --git a/.bumper/Tests/WhereProjectRulesTests.swift b/.bumper/Tests/WhereProjectRulesTests.swift index d42a8c7c9..204d5116d 100644 --- a/.bumper/Tests/WhereProjectRulesTests.swift +++ b/.bumper/Tests/WhereProjectRulesTests.swift @@ -3,6 +3,42 @@ import BumperBowlingTestSupport import Testing struct WhereProjectRulesTests { + @Test + func `only the regular runtime constructs the installation sidecar`() throws { + let source = "let store = FileInstallationRecordingContextStore()" + let allowed = try evaluate( + path: "Where/Where/Sources/RegularApplicationRuntime.swift", + component: .app, + source: source, + ) + let rejected = try evaluate( + path: "Where/WhereUI/Sources/Launch/CompetingContext.swift", + component: .whereUI, + source: source, + ) + #expect(allowed.violations.isEmpty) + #expect(rejected.violations.count == 1) + #expect(rejected.violations.first?.rule.id == "where.installation_context_ownership") + } + + @Test + func `sidecar preparation stays at the first unlock owner`() throws { + let source = "func prepare() throws { try store.prepareAfterFirstUnlock() }" + let allowed = try evaluate( + path: "Where/Where/Sources/RegularApplicationRuntime.swift", + component: .app, + source: source, + ) + let rejected = try evaluate( + path: "Where/WhereUI/Sources/Launch/CompetingContext.swift", + component: .whereUI, + source: source, + ) + #expect(allowed.violations.isEmpty) + #expect(rejected.violations.count == 1) + #expect(rejected.violations.first?.rule.id == "where.installation_context_preparation") + } + @Test func `production store opens at process composition roots`() throws { let allowed = try evaluate( diff --git a/Ledger/LedgerCore/README.md b/Ledger/LedgerCore/README.md index 32d0fbc4c..5fb1e7b37 100644 --- a/Ledger/LedgerCore/README.md +++ b/Ledger/LedgerCore/README.md @@ -32,10 +32,14 @@ A missing file/key is simply "no auto-token", surfaced as `LoadError.missingCred - `LedgerServices` — the `@MainActor @Observable` root: `loadState`, `lastUpdated`, `hasManualToken`, `autoTokenAvailable`, `settings`, `startsAtLogin`, `refresh()`, `setManualToken(_:)` / `clearManualToken()`, `start()` / `stop()`. - `SessionToken` / `SessionTokenSource` / `CursorLocalTokenSource` — the auth seam. - `DashboardProvider` + `CursorDashboardAPI` — the network seam. -- `ModelName` — parses a raw model id (`claude-opus-4-8-thinking-xhigh`, `github_bugbot`, …) into a friendly `displayName` + `badges` (effort/speed/mode). -- `UsageSummary`, `UsageEvent`/`UsageEventsPage`, `SpendSnapshot` — the wire + view models (cents are integers). -- `KeychainStore` / `SystemKeychainStore` — a pasted token's storage. -- `LedgerSettings` / `LedgerConfiguration` / `LedgerConfigStore` — the persisted refresh interval (no secrets). +- `ModelName` — parses a raw model id (`claude-opus-4-8-thinking-xhigh`, + `github_bugbot`, …) into a friendly `displayName` + `badges` (effort/speed/mode). +- `UsageSummary`, `UsageEvent`/`UsageEventsPage`, `SpendSnapshot` — the wire + view models + (cents are integers). +- `KeychainStore` / `SystemKeychainStore` — a thin string adapter over shared + `KeychainKit` for a pasted token's storage. +- `LedgerSettings` / `LedgerConfiguration` / `LedgerConfigStore` — the persisted + refresh interval (no secrets). - `LoginItemController` — launch-at-login via `SMAppService`. - **`LedgerLog`** — the Periscope logging facade: a `"Ledger"` root scope with grouping scopes (`services`, `dashboard`), emitted into `Periscope.shared`. diff --git a/Ledger/LedgerCore/Sources/KeychainStore.swift b/Ledger/LedgerCore/Sources/KeychainStore.swift index d2c7c2e98..89235c405 100644 --- a/Ledger/LedgerCore/Sources/KeychainStore.swift +++ b/Ledger/LedgerCore/Sources/KeychainStore.swift @@ -1,19 +1,9 @@ import Foundation -import Security +import KeychainKit /// A failure reading or writing the Keychain. Wraps the raw `OSStatus` so a /// caller can log something actionable rather than swallowing the error. -public struct KeychainError: LocalizedError, Equatable, Sendable { - public let status: OSStatus - public init(status: OSStatus) { - self.status = status - } - - public var errorDescription: String? { - let message = SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error" - return "\(message) (OSStatus \(status))" - } -} +public typealias KeychainError = KeychainKit.KeychainError /// Stores a single secret string (a pasted Cursor session token) securely. /// The seam is a protocol so tests use an in-memory fake — the real Keychain @@ -35,44 +25,21 @@ public protocol KeychainStore: Sendable { /// old Foreman app), so it reaches the default login Keychain without a /// keychain-access-group entitlement. public struct SystemKeychainStore: KeychainStore { - private let service: String - private let account: String + private let backing: KeychainKit.SystemKeychainStore /// Defaults to the app's bundle-style service and a fixed account name; /// there is only ever one secret (a pasted session token). public init(service: String = "com.stuff.ledger", account: String = "session-token") { - self.service = service - self.account = account - } - - private var baseQuery: [String: Any] { - [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - ] + backing = KeychainKit.SystemKeychainStore( + service: service, + account: account, + accessibility: .whenUnlocked, + synchronizesThroughICloud: false, + ) } public func read() throws -> String? { - var query = baseQuery - query[kSecReturnData as String] = true - query[kSecMatchLimit as String] = kSecMatchLimitOne - - var item: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &item) - switch status { - case errSecSuccess: - guard let data = item as? Data, - let string = String(data: data, encoding: .utf8) - else { - return nil - } - return string - case errSecItemNotFound: - return nil - default: - throw KeychainError(status: status) - } + try backing.readString() } public func write(_ secret: String) throws { @@ -82,29 +49,10 @@ public struct SystemKeychainStore: KeychainStore { return } - let data = Data(trimmed.utf8) - let attributes: [String: Any] = [kSecValueData as String: data] - - let updateStatus = SecItemUpdate(baseQuery as CFDictionary, attributes as CFDictionary) - switch updateStatus { - case errSecSuccess: - return - case errSecItemNotFound: - var addQuery = baseQuery - addQuery[kSecValueData as String] = data - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) - guard addStatus == errSecSuccess else { - throw KeychainError(status: addStatus) - } - default: - throw KeychainError(status: updateStatus) - } + try backing.write(trimmed) } public func remove() throws { - let status = SecItemDelete(baseQuery as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { - throw KeychainError(status: status) - } + try backing.remove() } } diff --git a/Package.swift b/Package.swift index 933ad2e17..1e41b4762 100644 --- a/Package.swift +++ b/Package.swift @@ -14,6 +14,7 @@ let package = Package( .library(name: "LifecycleKit", targets: ["LifecycleKit"]), .library(name: "LifecycleKitUI", targets: ["LifecycleKitUI"]), .library(name: "JournalKit", targets: ["JournalKit"]), + .library(name: "KeychainKit", targets: ["KeychainKit"]), .library(name: "PeriscopeCore", targets: ["PeriscopeCore"]), .library(name: "PeriscopeUI", targets: ["PeriscopeUI"]), .library(name: "PeriscopeTools", targets: ["PeriscopeTools"]), @@ -52,6 +53,7 @@ let package = Package( .target( name: "LedgerCore", dependencies: [ + .target(name: "KeychainKit"), .target(name: "PeriscopeCore"), ], path: "Ledger/LedgerCore/Sources", @@ -75,6 +77,10 @@ let package = Package( name: "JournalKit", path: "Shared/JournalKit/Sources", ), + .target( + name: "KeychainKit", + path: "Shared/KeychainKit/Sources", + ), .target( name: "PeriscopeCore", dependencies: [ @@ -162,6 +168,7 @@ let package = Package( dependencies: [ .target(name: "CreditKit"), .target(name: "JournalKit"), + .target(name: "KeychainKit"), .target(name: "PeriscopeCore"), .target(name: "RegionKit"), .product(name: "ZIPFoundation", package: "ZIPFoundation"), diff --git a/Project.swift b/Project.swift index 9a055b96a..dbdb52776 100644 --- a/Project.swift +++ b/Project.swift @@ -63,7 +63,13 @@ let whereAppEntitlements: Entitlements = .dictionary([ "com.apple.developer.icloud-container-identifiers": .array([ .string("iCloud.com.stuff.where"), ]), - "com.apple.developer.icloud-services": .array([.string("CloudKit")]), + "com.apple.developer.ubiquity-container-identifiers": .array([ + .string("iCloud.com.stuff.where"), + ]), + "com.apple.developer.icloud-services": .array([ + .string("CloudKit"), + .string("CloudDocuments"), + ]), "com.apple.developer.ubiquity-kvstore-identifier": .string( "$(TeamIdentifierPrefix)com.stuff.where", ), @@ -193,7 +199,30 @@ let project = Project( infoPlist: .extendingDefault(with: [ "UILaunchScreen": .dictionary([:]), "UIApplicationSupportsIndirectInputEvents": .boolean(true), - "UIBackgroundModes": .array([.string("remote-notification")]), + "UIBackgroundModes": .array([ + .string("remote-notification"), + .string("processing"), + ]), + "BGTaskSchedulerPermittedIdentifiers": .array([ + .string("com.stuff.where.automatic-backup"), + ]), + "NSUbiquitousContainers": .dictionary([ + "iCloud.com.stuff.where": .dictionary([ + "NSUbiquitousContainerIsDocumentScopePublic": .boolean(true), + "NSUbiquitousContainerName": .string("Where"), + "NSUbiquitousContainerSupportedFolderLevels": .string("Any"), + ]), + ]), + "UTExportedTypeDeclarations": .array([ + .dictionary([ + "UTTypeConformsTo": .array([.string("public.zip-archive")]), + "UTTypeDescription": .string("Where Encrypted Backup"), + "UTTypeIdentifier": .string("com.stuff.where.encrypted-backup"), + "UTTypeTagSpecification": .dictionary([ + "public.filename-extension": .array([.string("wherebackup")]), + ]), + ]), + ]), // Stated explicitly rather than left to Tuist's `1.0` / `1` // defaults, because Settings > About shows them: the version a // user reads off the screen should be one this manifest chose. @@ -471,6 +500,12 @@ let project = Project( productDependency: "JournalKit", sources: ["Shared/JournalKit/Tests/**"], ), + unitTests( + name: "KeychainKitTests", + bundleIdSuffix: "keychainkit", + productDependency: "KeychainKit", + sources: ["Shared/KeychainKit/Tests/**"], + ), unitTests( name: "PeriscopeCoreTests", bundleIdSuffix: "periscopecore", @@ -730,6 +765,7 @@ let project = Project( "LifecycleKitTests", "LifecycleKitUITests", "JournalKitTests", + "KeychainKitTests", "PeriscopeCoreTests", "PeriscopeUITests", "PeriscopeToolsTests", @@ -754,6 +790,7 @@ let project = Project( "LifecycleKitTests", "LifecycleKitUITests", "JournalKitTests", + "KeychainKitTests", "PeriscopeCoreTests", "PeriscopeUITests", "PeriscopeToolsTests", @@ -779,6 +816,7 @@ let project = Project( testScheme(name: "LifecycleKitTests"), testScheme(name: "LifecycleKitUITests"), testScheme(name: "JournalKitTests"), + testScheme(name: "KeychainKitTests"), testScheme(name: "PeriscopeCoreTests"), testScheme(name: "PeriscopeUITests"), testScheme(name: "PeriscopeToolsTests"), diff --git a/Shared/KeychainKit/AGENTS.md b/Shared/KeychainKit/AGENTS.md new file mode 100644 index 000000000..6832ed689 --- /dev/null +++ b/Shared/KeychainKit/AGENTS.md @@ -0,0 +1,15 @@ +# KeychainKit – Module Shape + +KeychainKit is the small cross-app Keychain boundary: generic-password storage +for opaque `Data`, with typed accessibility and iCloud-synchronization policy. +It depends only on Foundation and Security and never assigns product meaning to +service/account identifiers. See [`README.md`](README.md) and the root +[`AGENTS.md`](../../AGENTS.md). + +Keep the protocol injectable, keep raw `OSStatus` failures observable, and do +not turn an inaccessible item into `notFound`; callers decide whether absence +permits creating a new secret. Tests use the in-memory store SPI, never a +user's Keychain. Run `./test KeychainKitTests`. + +Keep collection entries create-only. Do not treat a successful local insert +as a cross-device uniqueness guarantee (`KeychainCollectionTests`). diff --git a/Shared/KeychainKit/README.md b/Shared/KeychainKit/README.md new file mode 100644 index 000000000..9439aaf43 --- /dev/null +++ b/Shared/KeychainKit/README.md @@ -0,0 +1,19 @@ +# KeychainKit + +KeychainKit provides a focused, injectable wrapper around generic-password +items in Apple Keychain Services. `SystemKeychainStore` stores opaque `Data` +under an explicit service/account pair and supports typed accessibility and +iCloud Keychain synchronization policy; `KeychainStore` lets consumers use an +in-memory implementation in tests. `create(_:)` inserts without replacing an +existing local item. It is not a distributed lock between devices. +`write(_:)` inserts or updates an item. + +`KeychainCollection` and `SystemKeychainCollection` provide append-only storage +under typed `KeychainAccount` identifiers. Give independent secrets different +accounts so eventual iCloud synchronization can retain all of them. Consumers +can use `InMemoryKeychainCollection` through the testing SPI. + +The module deliberately does not generate, parse, or rotate secrets. Product +modules own those rules and must distinguish `nil` (the item does not exist) +from a thrown `KeychainError`, including `errSecInteractionNotAllowed` while +protected data is unavailable. diff --git a/Shared/KeychainKit/Sources/KeychainCollection.swift b/Shared/KeychainKit/Sources/KeychainCollection.swift new file mode 100644 index 000000000..adabdafff --- /dev/null +++ b/Shared/KeychainKit/Sources/KeychainCollection.swift @@ -0,0 +1,69 @@ +import Foundation +import os +import Security + +/// An append-only collection of independently synchronized secrets. Creating +/// different accounts never competes to replace one shared mutable item. +public protocol KeychainCollection: Sendable { + func read(account: KeychainAccount) throws -> Data? + func create(_ data: Data, account: KeychainAccount) throws +} + +public struct KeychainAccount: Hashable, Sendable { + public let rawValue: String + + public init(_ rawValue: String) { + self.rawValue = rawValue + } +} + +public struct SystemKeychainCollection: KeychainCollection { + private let service: String + private let accessibility: KeychainAccessibility + private let synchronizesThroughICloud: Bool + + public init( + service: String, + accessibility: KeychainAccessibility, + synchronizesThroughICloud: Bool, + ) { + self.service = service + self.accessibility = accessibility + self.synchronizesThroughICloud = synchronizesThroughICloud + } + + public func read(account: KeychainAccount) throws -> Data? { + try store(account: account).read() + } + + public func create(_ data: Data, account: KeychainAccount) throws { + try store(account: account).create(data) + } + + private func store(account: KeychainAccount) -> SystemKeychainStore { + SystemKeychainStore( + service: service, + account: account.rawValue, + accessibility: accessibility, + synchronizesThroughICloud: synchronizesThroughICloud, + ) + } +} + +@_spi(Testing) +public final class InMemoryKeychainCollection: KeychainCollection { + private let items = OSAllocatedUnfairLock<[KeychainAccount: Data]>(initialState: [:]) + + public init() {} + + public func read(account: KeychainAccount) -> Data? { + items.withLock { $0[account] } + } + + public func create(_ data: Data, account: KeychainAccount) throws { + try items.withLock { + guard $0[account] == nil else { throw KeychainError(status: errSecDuplicateItem) } + $0[account] = data + } + } +} diff --git a/Shared/KeychainKit/Sources/KeychainStore.swift b/Shared/KeychainKit/Sources/KeychainStore.swift new file mode 100644 index 000000000..a508fa079 --- /dev/null +++ b/Shared/KeychainKit/Sources/KeychainStore.swift @@ -0,0 +1,192 @@ +import Foundation +import os +import Security + +/// A raw Keychain Services failure. Missing items are represented by `nil`, +/// while inaccessible or malformed items remain observable errors. +public struct KeychainError: LocalizedError, Equatable, Sendable { + public let status: OSStatus + + public init(status: OSStatus) { + self.status = status + } + + public var isInteractionNotAllowed: Bool { + status == errSecInteractionNotAllowed + } + + public var errorDescription: String? { + let message = SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error" + return "\(message) (OSStatus \(status))" + } +} + +/// When a stored item is available relative to device lock state. +public enum KeychainAccessibility: Sendable, Hashable { + case whenUnlocked + case afterFirstUnlock + + var securityValue: CFString { + switch self { + case .whenUnlocked: kSecAttrAccessibleWhenUnlocked + case .afterFirstUnlock: kSecAttrAccessibleAfterFirstUnlock + } + } +} + +/// Storage boundary for one opaque generic-password item. +public protocol KeychainStore: Sendable { + func read() throws -> Data? + func create(_ data: Data) throws + func write(_ data: Data) throws + func remove() throws +} + +extension KeychainStore { + public func readString() throws -> String? { + guard let data = try read() else { return nil } + guard let value = String(data: data, encoding: .utf8) else { + throw KeychainError(status: errSecDecode) + } + return value + } + + public func write(_ value: String) throws { + try write(Data(value.utf8)) + } +} + +/// A generic-password item identified by an explicit service and account. +public struct SystemKeychainStore: KeychainStore, Sendable { + public let service: String + public let account: String + public let accessibility: KeychainAccessibility + public let synchronizesThroughICloud: Bool + + public init( + service: String, + account: String, + accessibility: KeychainAccessibility, + synchronizesThroughICloud: Bool, + ) { + self.service = service + self.account = account + self.accessibility = accessibility + self.synchronizesThroughICloud = synchronizesThroughICloud + } + + private var baseQuery: [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + if synchronizesThroughICloud { + query[kSecAttrSynchronizable as String] = kCFBooleanTrue + } + return query + } + + public func read() throws -> Data? { + var query = baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + switch status { + case errSecSuccess: + guard let data = item as? Data else { + throw KeychainError(status: errSecDecode) + } + return data + case errSecItemNotFound: + return nil + default: + throw KeychainError(status: status) + } + } + + public func write(_ data: Data) throws { + let attributes: [String: Any] = [kSecValueData as String: data] + let updateStatus = SecItemUpdate(baseQuery as CFDictionary, attributes as CFDictionary) + switch updateStatus { + case errSecSuccess: + return + case errSecItemNotFound: + do { + try create(data) + } catch let error as KeychainError where error.status == errSecDuplicateItem { + // A concurrent writer may have inserted the item between + // update and add. Preserve upsert semantics by retrying the + // update rather than replacing the caller-visible contract + // with a spurious duplicate failure. + let retryStatus = SecItemUpdate( + baseQuery as CFDictionary, + attributes as CFDictionary, + ) + guard retryStatus == errSecSuccess else { + throw KeychainError(status: retryStatus) + } + } + default: + throw KeychainError(status: updateStatus) + } + } + + /// Inserts only when no matching local item exists. This does not provide + /// mutual exclusion between devices that have not synchronized yet. + public func create(_ data: Data) throws { + var query = baseQuery + query[kSecAttrAccessible as String] = accessibility.securityValue + query[kSecValueData as String] = data + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw KeychainError(status: status) + } + } + + public func remove() throws { + let status = SecItemDelete(baseQuery as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError(status: status) + } + } +} + +/// Deterministic Keychain storage for consumers' unit tests. +@_spi(Testing) +public final class InMemoryKeychainStore: KeychainStore { + private let data: OSAllocatedUnfairLock + private let failure: KeychainError? + + public init(data: Data? = nil, failure: KeychainError? = nil) { + self.data = OSAllocatedUnfairLock(initialState: data) + self.failure = failure + } + + public func read() throws -> Data? { + if let failure { throw failure } + return data.withLock { $0 } + } + + public func write(_ newValue: Data) throws { + if let failure { throw failure } + data.withLock { $0 = newValue } + } + + public func create(_ newValue: Data) throws { + if let failure { throw failure } + try data.withLock { value in + guard value == nil else { + throw KeychainError(status: errSecDuplicateItem) + } + value = newValue + } + } + + public func remove() throws { + if let failure { throw failure } + data.withLock { $0 = nil } + } +} diff --git a/Shared/KeychainKit/Tests/KeychainCollectionTests.swift b/Shared/KeychainKit/Tests/KeychainCollectionTests.swift new file mode 100644 index 000000000..c752b1a50 --- /dev/null +++ b/Shared/KeychainKit/Tests/KeychainCollectionTests.swift @@ -0,0 +1,20 @@ +import Foundation +@_spi(Testing) import KeychainKit +import Security +import Testing + +struct KeychainCollectionTests { + @Test func preservesIndependentAccountsAndRejectsReplacement() throws { + let store = InMemoryKeychainCollection() + let first = KeychainAccount("first") + let second = KeychainAccount("second") + #expect(store.read(account: first) == nil) + try store.create(Data([1]), account: first) + try store.create(Data([2]), account: second) + #expect(throws: KeychainError(status: errSecDuplicateItem)) { + try store.create(Data([3]), account: first) + } + #expect(store.read(account: first) == Data([1])) + #expect(store.read(account: second) == Data([2])) + } +} diff --git a/Shared/KeychainKit/Tests/KeychainStoreTests.swift b/Shared/KeychainKit/Tests/KeychainStoreTests.swift new file mode 100644 index 000000000..e56f57177 --- /dev/null +++ b/Shared/KeychainKit/Tests/KeychainStoreTests.swift @@ -0,0 +1,54 @@ +import Foundation +@_spi(Testing) import KeychainKit +import Security +import Testing + +struct KeychainStoreTests { + @Test func errorIdentifiesInteractionNotAllowed() { + let error = KeychainError(status: errSecInteractionNotAllowed) + #expect(error.isInteractionNotAllowed) + } + + @Test func otherErrorsAreNotInteractionNotAllowed() { + let error = KeychainError(status: errSecDecode) + #expect(error.isInteractionNotAllowed == false) + } + + @Test func dataRoundTripsAndCanBeRemoved() throws { + let store = InMemoryKeychainStore() + let value = Data([0, 1, 2, 255]) + + try store.write(value) + #expect(try store.read() == value) + + try store.remove() + #expect(try store.read() == nil) + } + + @Test func stringsRoundTrip() throws { + let store = InMemoryKeychainStore() + + try store.write("secret") + + #expect(try store.readString() == "secret") + } + + @Test func createNeverOverwritesAnExistingItem() throws { + let original = Data("original".utf8) + let store = InMemoryKeychainStore(data: original) + + #expect(throws: KeychainError(status: errSecDuplicateItem)) { + try store.create(Data("replacement".utf8)) + } + #expect(try store.read() == original) + } + + @Test func injectedFailuresRemainObservable() { + let expected = KeychainError(status: errSecInteractionNotAllowed) + let store = InMemoryKeychainStore(failure: expected) + + #expect(throws: expected) { + try store.read() + } + } +} diff --git a/Where/Specifications/AutomaticBackupLifecycle/AutomaticBackupLifecycle.tla b/Where/Specifications/AutomaticBackupLifecycle/AutomaticBackupLifecycle.tla new file mode 100644 index 000000000..89b6df1f4 --- /dev/null +++ b/Where/Specifications/AutomaticBackupLifecycle/AutomaticBackupLifecycle.tla @@ -0,0 +1,198 @@ +---- MODULE AutomaticBackupLifecycle ---- +EXTENDS Integers, FiniteSets + +CONSTANTS Implementation, MaxRuns +ASSUME /\ Implementation \in {"candidate", "review-ui", "review-unlock", "broken-drain", "broken-generation"} + /\ MaxRuns \in 1..2 + +Runs == 1..MaxRuns +Stages == {"unused", "key", "snapshot", "staging", "writing", "committed", "success", "done"} + +(* --algorithm AutomaticBackupLifecycleAlgorithm { +variables unlocked = FALSE, + context = "unloaded", + earlyContextRead = FALSE, + availability = "active", + generation = 0, + enabled = TRUE, + disabledOnce = FALSE, + admitted = 0, + active = 0, + stage = [r \in Runs |-> "unused"], + runGeneration = [r \in Runs |-> 0], + cancelled = {}, + committed = {}, + successful = {}, + published = 0, + uiJoined = 0, + uiDismissed = FALSE, + accidentalCancellation = FALSE, + expired = FALSE, + lateWrite = FALSE, + sawInFlightRetirement = FALSE; + +fair process (Unlock = "unlock") { +UnlockStep: + while (TRUE) { + await ~unlocked; + unlocked := TRUE; + } +} +fair process (LoadContext = "context") { +LoadContextStep: + while (TRUE) { + await context = "unloaded" /\ (unlocked \/ Implementation = "review-unlock"); + context := IF unlocked THEN "ready" ELSE "failed" || + earlyContextRead := ~unlocked; + } +} +process (AdmitOrJoin = "admit") { +AdmitOrJoinStep: + while (TRUE) { + await unlocked /\ context = "ready" /\ availability = "active" /\ enabled; + if (active = 0) { + await admitted < MaxRuns; + active := admitted + 1 || + admitted := admitted + 1 || + stage[admitted + 1] := "key" || + runGeneration[admitted + 1] := generation; + } else { + await uiJoined = 0 /\ ~uiDismissed; + uiJoined := active; + }; + } +} +process (DismissUI = "dismiss") { +DismissUIStep: + while (TRUE) { + await uiJoined # 0 /\ ~uiDismissed; + uiDismissed := TRUE; + if (Implementation = "review-ui" /\ stage[uiJoined] # "done") { + cancelled := cancelled \cup {uiJoined} || + accidentalCancellation := TRUE; + }; + } +} +process (Disable = "disable") { +DisableStep: + while (TRUE) { + await ~disabledOnce; + enabled := FALSE || disabledOnce := TRUE || + cancelled := IF active # 0 THEN cancelled \cup {active} ELSE cancelled; + } +} +process (Expire = "expire") { +ExpireStep: + while (TRUE) { + await active # 0 /\ ~expired; + expired := TRUE || cancelled := cancelled \cup {active}; + } +} +fair process (Prepare = "prepare") { +PrepareStep: + while (TRUE) { + with (r \in Runs) { + await stage[r] \in {"key", "snapshot", "staging"}; + stage[r] := IF r \in cancelled THEN "done" + ELSE CASE stage[r] = "key" -> "snapshot" + [] stage[r] = "snapshot" -> "staging" + [] OTHER -> "writing"; + }; + } +} +fair process (Write = "write") { +WriteStep: + while (TRUE) { + with (r \in Runs) { + await stage[r] = "writing"; + either { + stage[r] := "committed" || committed := committed \cup {r} || + lateWrite := lateWrite \/ availability = "retired"; + } or { + stage[r] := "done"; + }; + }; + } +} +fair process (RecordSuccess = "success") { +RecordSuccessStep: + while (TRUE) { + with (r \in Runs) { + await stage[r] = "committed"; + stage[r] := IF r \in cancelled THEN "done" ELSE "success" || + successful := IF r \in cancelled THEN successful ELSE successful \cup {r}; + }; + } +} +fair process (FinishMaintenance = "maintenance") { +FinishMaintenanceStep: + while (TRUE) { + with (r \in Runs) { + await stage[r] = "success"; + \* Success and failure of maintenance both preserve the committed result. + stage[r] := "done"; + }; + } +} +fair process (ReleaseRun = "release") { +ReleaseRunStep: + while (TRUE) { + await active # 0 /\ stage[active] = "done"; + active := 0; + } +} +process (PublishPreference = "publish") { +PublishPreferenceStep: + while (TRUE) { + with (r \in successful) { + await stage[r] = "done"; + if (runGeneration[r] = generation \/ Implementation = "broken-generation") { + published := r; + }; + }; + } +} +process (BeginRetirement = "retire") { +BeginRetirementStep: + while (TRUE) { + await availability = "active"; + availability := "closing" || + sawInFlightRetirement := active # 0 || + cancelled := IF active # 0 THEN cancelled \cup {active} ELSE cancelled; + } +} +fair process (Drain = "drain") { +DrainStep: + while (TRUE) { + await availability = "closing" /\ (active = 0 \/ Implementation = "broken-drain"); + availability := "retired" || generation := 1 || published := 0; + } +} +process (Idle = "idle") { +IdleStep: + while (TRUE) { skip; } +} +} *) + +TypeOK == + /\ unlocked \in BOOLEAN /\ earlyContextRead \in BOOLEAN + /\ context \in {"unloaded", "failed", "ready"} + /\ availability \in {"active", "closing", "retired"} + /\ generation \in 0..1 /\ enabled \in BOOLEAN /\ disabledOnce \in BOOLEAN + /\ admitted \in 0..MaxRuns /\ active \in 0..MaxRuns + /\ stage \in [Runs -> Stages] /\ runGeneration \in [Runs -> 0..1] + /\ cancelled \subseteq Runs /\ committed \subseteq Runs /\ successful \subseteq Runs + /\ published \in 0..MaxRuns /\ uiJoined \in 0..MaxRuns + /\ uiDismissed \in BOOLEAN /\ accidentalCancellation \in BOOLEAN + /\ expired \in BOOLEAN /\ lateWrite \in BOOLEAN /\ sawInFlightRetirement \in BOOLEAN +NoEarlyContextRead == ~earlyContextRead +UIHasNoCancellationAuthority == ~accidentalCancellation +SingleFlight == Cardinality({r \in Runs : stage[r] \notin {"unused", "done"}}) <= 1 +NoWriteAfterRetirement == ~lateWrite +SuccessRequiresCommit == successful \subseteq committed +NoStalePreference == published = 0 \/ runGeneration[published] = generation +RetirementDrained == availability = "retired" => \A r \in Runs : stage[r] \in {"unused", "done"} +EventuallyReady == <> (context = "ready") +EventuallyDrained == (availability = "closing") ~> (availability = "retired") +CriticalStateNotReached == ~(sawInFlightRetirement /\ availability = "retired" /\ committed # {}) +==== diff --git a/Where/Specifications/AutomaticBackupLifecycle/BrokenDrain.cfg b/Where/Specifications/AutomaticBackupLifecycle/BrokenDrain.cfg new file mode 100644 index 000000000..3611e10dc --- /dev/null +++ b/Where/Specifications/AutomaticBackupLifecycle/BrokenDrain.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "broken-drain" + MaxRuns = 1 +INVARIANTS + TypeOK + NoWriteAfterRetirement +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/AutomaticBackupLifecycle/BrokenGeneration.cfg b/Where/Specifications/AutomaticBackupLifecycle/BrokenGeneration.cfg new file mode 100644 index 000000000..06938b6cb --- /dev/null +++ b/Where/Specifications/AutomaticBackupLifecycle/BrokenGeneration.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "broken-generation" + MaxRuns = 1 +INVARIANTS + TypeOK + NoStalePreference +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/AutomaticBackupLifecycle/Candidate.cfg b/Where/Specifications/AutomaticBackupLifecycle/Candidate.cfg new file mode 100644 index 000000000..07142f2db --- /dev/null +++ b/Where/Specifications/AutomaticBackupLifecycle/Candidate.cfg @@ -0,0 +1,17 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "candidate" + MaxRuns = 1 +INVARIANTS + TypeOK + NoEarlyContextRead + UIHasNoCancellationAuthority + SingleFlight + NoWriteAfterRetirement + SuccessRequiresCommit + NoStalePreference + RetirementDrained +PROPERTIES + EventuallyReady + EventuallyDrained +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/AutomaticBackupLifecycle/CandidateTwoRuns.cfg b/Where/Specifications/AutomaticBackupLifecycle/CandidateTwoRuns.cfg new file mode 100644 index 000000000..0f39ad7cd --- /dev/null +++ b/Where/Specifications/AutomaticBackupLifecycle/CandidateTwoRuns.cfg @@ -0,0 +1,17 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "candidate" + MaxRuns = 2 +INVARIANTS + TypeOK + NoEarlyContextRead + UIHasNoCancellationAuthority + SingleFlight + NoWriteAfterRetirement + SuccessRequiresCommit + NoStalePreference + RetirementDrained +PROPERTIES + EventuallyReady + EventuallyDrained +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/AutomaticBackupLifecycle/README.md b/Where/Specifications/AutomaticBackupLifecycle/README.md new file mode 100644 index 000000000..58fa5bc8d --- /dev/null +++ b/Where/Specifications/AutomaticBackupLifecycle/README.md @@ -0,0 +1,87 @@ +# Automatic backup lifecycle + +This model checks admission, cancellation, file commit, and scope retirement. +It also checks first-unlock preparation and late preference publication. +It supplies bounded protocol evidence, not a proof of Swift or iOS behavior. + +## Source correspondence + +| Model state or action | Swift boundary | +| --- | --- | +| `UnlockStep`, `LoadContextStep` | `FirstUnlockAvailability.waitUntilAvailable()` and `FileInstallationRecordingContextStore.prepareAfterFirstUnlock()` | +| `context` | Installation sidecar resolution before `WhereLaunch` reaches onboarding | +| `AdmitOrJoinStep`, `active` | `AutomaticBackupService.runIfDue` selects or creates its owned task without an intervening suspension | +| `DismissUIStep` | A Data-page caller uses `CallerCancellation.finishExecution` | +| `DisableStep`, `ExpireStep` | Configuration disable or an execution owner's cancellation cancels the shared task | +| `PrepareStep` | Cancellation checks between key access, snapshot loading, and archive staging | +| `WriteStep`, `committed` | The atomic file move in `AutomaticBackupStorage.write` | +| `RecordSuccessStep`, `successful` | The cancellation check and success timestamp after the storage call returns | +| `FinishMaintenanceStep` | Retention completes or reports failure without undoing export success | +| `ReleaseRunStep` | The joined caller clears the completed run by identity | +| `BeginRetirementStep`, `DrainStep` | Service shutdown cancels and awaits the run before scope replacement | +| `PublishPreferenceStep`, `generation` | `WherePreferences.recordAutomaticBackupSuccess` rejects an obsolete reset generation | + +Each labelled process step is atomic. Separate steps permit cancellation or +retirement between operations. `WriteStep` may commit after cancellation was +requested: cancellation cannot revoke a file move already past its last check. +Retirement must therefore await completion, rather than merely request cancellation. + +A disappearing view still awaits the shared result. It records successful +metadata before returning, but does not refresh the disappeared view. +It has no authority to cancel the shared export. + +## Properties and bounds + +The candidate configurations check all types and these safety properties: + +- No installation-context read occurs before first unlock. +- UI disappearance cannot cancel the export. +- At most one admitted run has unfinished work. +- No file commit occurs after retirement returns. +- Every recorded success has a committed file. +- Published preferences belong to the current generation. +- Retirement returns only after all admitted work finishes. + +`EventuallyReady` assumes the user eventually unlocks the device and context +loading succeeds. `EventuallyDrained` assumes admitted I/O eventually returns, +including cancellation acknowledgements. Neither property claims an iOS deadline. +Weak fairness applies only to those completion actions. User commands and +backup admission have no fairness assumption. `IdleStep` permits process stuttering. + +The finite bounds are one installation, one retirement, one disable, one +expiration, one UI caller, and one or two admitted runs. +Calendar scheduling, onboarding choices, failed reset resumption, and a newly +created scope are excluded. Store transactions and cryptography are abstracted. +Tests, not this model, check scheduler revisions and exact interval calculations. + +## Controls and results + +The checked candidate state spaces on 2026-09-07 were: + +| Case | Generated / distinct states | Depth | +| --- | ---: | ---: | +| `candidate` | 1,722 / 486 | 17 | +| `candidate-two-runs` | 16,420 / 4,066 | 25 | + +`review-ui` and `review-unlock` reproduce the reviewed cancellation and eager +sidecar-load defects. `broken-drain` permits retirement before the write returns. +`broken-generation` permits an old caller to publish after reset. +Each negative control must violate its named safety invariant. + +The reachability control must find an in-flight retirement with a committed +file. Its expected failure proves that this boundary is reachable. +The manifest records the expected result of every case. + +Swift regression coverage includes `FirstUnlockAvailabilityTests`, +`InstallationRecordingContextStoreTests`, `PrepareProtectedDataStepTests`, +`AutomaticBackupServiceTests`, and `BackupModelTests`. + +## Run + +```sh +./tla-check AutomaticBackupLifecycle +``` + +The checker retains translations, logs, state counts, and tool checksums in +`.build/tla/runs/`. See the [specification workflow](../README.md). +Recheck this mapping after changing any corresponding Swift boundary. diff --git a/Where/Specifications/AutomaticBackupLifecycle/Reachability.cfg b/Where/Specifications/AutomaticBackupLifecycle/Reachability.cfg new file mode 100644 index 000000000..f129d90f0 --- /dev/null +++ b/Where/Specifications/AutomaticBackupLifecycle/Reachability.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "candidate" + MaxRuns = 1 +INVARIANTS + TypeOK + CriticalStateNotReached +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/AutomaticBackupLifecycle/ReviewUI.cfg b/Where/Specifications/AutomaticBackupLifecycle/ReviewUI.cfg new file mode 100644 index 000000000..092d1fbfd --- /dev/null +++ b/Where/Specifications/AutomaticBackupLifecycle/ReviewUI.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "review-ui" + MaxRuns = 1 +INVARIANTS + TypeOK + UIHasNoCancellationAuthority +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/AutomaticBackupLifecycle/ReviewUnlock.cfg b/Where/Specifications/AutomaticBackupLifecycle/ReviewUnlock.cfg new file mode 100644 index 000000000..04d711a2f --- /dev/null +++ b/Where/Specifications/AutomaticBackupLifecycle/ReviewUnlock.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "review-unlock" + MaxRuns = 1 +INVARIANTS + TypeOK + NoEarlyContextRead +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/AutomaticBackupLifecycle/manifest.json b/Where/Specifications/AutomaticBackupLifecycle/manifest.json new file mode 100644 index 000000000..ce1294ac9 --- /dev/null +++ b/Where/Specifications/AutomaticBackupLifecycle/manifest.json @@ -0,0 +1,46 @@ +{ + "source": "pluscal", + "module": "AutomaticBackupLifecycle.tla", + "cases": [ + { + "name": "candidate", + "config": "Candidate.cfg", + "expect": "pass" + }, + { + "name": "candidate-two-runs", + "config": "CandidateTwoRuns.cfg", + "expect": "pass" + }, + { + "name": "review-ui", + "config": "ReviewUI.cfg", + "expect": "fail", + "outputContains": "Invariant UIHasNoCancellationAuthority is violated." + }, + { + "name": "review-unlock", + "config": "ReviewUnlock.cfg", + "expect": "fail", + "outputContains": "Invariant NoEarlyContextRead is violated." + }, + { + "name": "broken-drain", + "config": "BrokenDrain.cfg", + "expect": "fail", + "outputContains": "Invariant NoWriteAfterRetirement is violated." + }, + { + "name": "broken-generation", + "config": "BrokenGeneration.cfg", + "expect": "fail", + "outputContains": "Invariant NoStalePreference is violated." + }, + { + "name": "reachability", + "config": "Reachability.cfg", + "expect": "fail", + "outputContains": "Invariant CriticalStateNotReached is violated." + } + ] +} diff --git a/Where/Specifications/AutomaticBackupRetention/AutomaticBackupRetention.tla b/Where/Specifications/AutomaticBackupRetention/AutomaticBackupRetention.tla new file mode 100644 index 000000000..9afbb6245 --- /dev/null +++ b/Where/Specifications/AutomaticBackupRetention/AutomaticBackupRetention.tla @@ -0,0 +1,169 @@ +---- MODULE AutomaticBackupRetention ---- +EXTENDS Integers, FiniteSets + +CONSTANTS Implementation, DeviceCount +ASSUME /\ Implementation \in {"review", "candidate", "unauthenticated", "retry-on-maintenance", "stale-candidate"} + /\ DeviceCount \in 1..2 +Devices == 1..DeviceCount +ValidFiles == 1..(3 + DeviceCount) +Files == 1..(5 + DeviceCount) +Locations == 0..DeviceCount +UnknownKey == 4 + DeviceCount +Forged == 5 + DeviceCount +Retain == 3 +WritePhases == {"cloud", "local", "stored", "done"} +ScanPhases == {"idle", "validate", "delete", "done"} + +(* --algorithm AutomaticBackupRetentionAlgorithm { +variables copies = [f \in Files |-> IF f \in (1..3) \cup {UnknownKey, Forged} THEN {0} ELSE {}], + revision = [f \in Files |-> 0], + cloudAvailable = [d \in Devices |-> TRUE], + cloudTransitions = [d \in Devices |-> 0], + writePhase = [d \in Devices |-> "cloud"], + scanPhase = [d \in Devices |-> "idle"], + pending = [d \in Devices |-> {}], + verified = [d \in Devices |-> {}], + hashes = [d \in Devices |-> [f \in Files |-> 0]], + considered = [d \in Devices |-> {}], + mutated = FALSE, + maintenanceFailed = {}, + removedUnknown = FALSE, + removedChanged = FALSE, + unsafeDeletion = FALSE, + removed = {}; + +define { + Accessible(d) == {f \in Files : d \in copies[f] \/ (cloudAvailable[d] /\ 0 \in copies[f])} + Healthy(d) == {f \in Accessible(d) \cap ValidFiles : revision[f] = 0} + Keepers(d) == {f \in verified[d] : Cardinality({g \in verified[d] : g > f}) < Retain} + Candidates(d) == verified[d] \ Keepers(d) +} + +fair process (CloudWrite = "cloud-write") { +CloudWriteStep: + while (TRUE) { + with (d \in Devices) { + await writePhase[d] = "cloud"; + either { + await cloudAvailable[d]; + copies[3 + d] := copies[3 + d] \cup {0} || writePhase[d] := "stored"; + } or { + writePhase[d] := "local"; + }; + }; + } +} +fair process (LocalWrite = "local-write") { +LocalWriteStep: + while (TRUE) { + with (d \in Devices) { + await writePhase[d] = "local"; + either { + copies[3 + d] := copies[3 + d] \cup {d} || writePhase[d] := "stored"; + } or { + writePhase[d] := "done" || scanPhase[d] := "done"; + }; + }; + } +} +process (MaintenanceFailure = "maintenance-failure") { +MaintenanceFailureStep: + while (TRUE) { + with (d \in Devices \ maintenanceFailed) { + await writePhase[d] = "stored" /\ scanPhase[d] = "idle"; + maintenanceFailed := maintenanceFailed \cup {d}; + if (Implementation = "retry-on-maintenance") { writePhase[d] := "local"; }; + }; + } +} +fair process (Enumerate = "enumerate") { +EnumerateStep: + while (TRUE) { + with (d \in Devices) { + await writePhase[d] = "stored" /\ scanPhase[d] = "idle"; + pending[d] := Accessible(d) || scanPhase[d] := "validate" || writePhase[d] := "done"; + }; + } +} +fair process (Validate = "validate") { +ValidateStep: + while (TRUE) { + with (d \in Devices) { + await scanPhase[d] = "validate"; + if (pending[d] = {}) { scanPhase[d] := "delete"; } + else { + with (f = CHOOSE g \in pending[d] : \A h \in pending[d] : g >= h) { + pending[d] := pending[d] \ {f}; + if (f \in Healthy(d) \/ (Implementation = "unauthenticated" /\ f \in Accessible(d))) { + verified[d] := verified[d] \cup {f} || hashes[d][f] := revision[f]; + }; + }; + }; + }; + } +} +process (ChangeFile = "change-file") { +ChangeFileStep: + while (TRUE) { + await ~mutated; + with (f \in ValidFiles) { + await copies[f] # {}; + revision[f] := 1 || mutated := TRUE; + }; + } +} +process (ChangeAvailability = "availability") { +ChangeAvailabilityStep: + while (TRUE) { + with (d \in Devices) { + await cloudTransitions[d] < 2; + cloudAvailable[d] := ~cloudAvailable[d] || cloudTransitions[d] := cloudTransitions[d] + 1; + }; + } +} +fair process (Prune = "prune") { +PruneStep: + while (TRUE) { + with (d \in Devices) { + await scanPhase[d] = "delete"; + if (Candidates(d) \ considered[d] = {}) { scanPhase[d] := "done"; } + else { + with (f \in Candidates(d) \ considered[d]) { + considered[d] := considered[d] \cup {f}; + if (f \in Accessible(d) + /\ (revision[f] = hashes[d][f] \/ Implementation = "stale-candidate") + /\ (Implementation # "candidate" + \/ (Keepers(d) \subseteq Accessible(d) + /\ \A k \in Keepers(d) : revision[k] = hashes[d][k]))) { + copies[f] := {} || removed := removed \cup {f} || + removedUnknown := removedUnknown \/ f \notin ValidFiles || + removedChanged := removedChanged \/ revision[f] # hashes[d][f] || + unsafeDeletion := unsafeDeletion \/ Cardinality(Healthy(d) \ {f}) < Retain; + }; + }; + }; + }; + } +} +process (Idle = "idle") { +IdleStep: + while (TRUE) { skip; } +} +} *) + +TypeOK == + /\ copies \in [Files -> SUBSET Locations] /\ revision \in [Files -> 0..1] + /\ cloudAvailable \in [Devices -> BOOLEAN] /\ cloudTransitions \in [Devices -> 0..2] + /\ writePhase \in [Devices -> WritePhases] /\ scanPhase \in [Devices -> ScanPhases] + /\ pending \in [Devices -> SUBSET Files] /\ verified \in [Devices -> SUBSET Files] + /\ hashes \in [Devices -> [Files -> 0..1]] /\ considered \in [Devices -> SUBSET Files] + /\ mutated \in BOOLEAN /\ maintenanceFailed \subseteq Devices + /\ removedUnknown \in BOOLEAN /\ removedChanged \in BOOLEAN /\ unsafeDeletion \in BOOLEAN + /\ removed \subseteq Files +PreserveUnknownFiles == ~removedUnknown +PreserveChangedCandidates == ~removedChanged +DeletionLeavesThreeHealthyFiles == ~unsafeDeletion +NoDuplicateFallback == \A f \in Files : Cardinality(copies[f]) <= 1 +EventuallySettled == <> (\A d \in Devices : scanPhase[d] = "done") +CriticalStateNotReached == ~(mutated /\ removed # {} /\ maintenanceFailed # {}) +==== diff --git a/Where/Specifications/AutomaticBackupRetention/Candidate.cfg b/Where/Specifications/AutomaticBackupRetention/Candidate.cfg new file mode 100644 index 000000000..6eb9918f5 --- /dev/null +++ b/Where/Specifications/AutomaticBackupRetention/Candidate.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "candidate" + DeviceCount = 1 +INVARIANTS + TypeOK + PreserveUnknownFiles + PreserveChangedCandidates + DeletionLeavesThreeHealthyFiles + NoDuplicateFallback +PROPERTIES + EventuallySettled +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/AutomaticBackupRetention/CandidateTwoDevices.cfg b/Where/Specifications/AutomaticBackupRetention/CandidateTwoDevices.cfg new file mode 100644 index 000000000..66891fbc6 --- /dev/null +++ b/Where/Specifications/AutomaticBackupRetention/CandidateTwoDevices.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "candidate" + DeviceCount = 2 +INVARIANTS + TypeOK + PreserveUnknownFiles + PreserveChangedCandidates + DeletionLeavesThreeHealthyFiles + NoDuplicateFallback +PROPERTIES + EventuallySettled +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/AutomaticBackupRetention/README.md b/Where/Specifications/AutomaticBackupRetention/README.md new file mode 100644 index 000000000..bdaf084a7 --- /dev/null +++ b/Where/Specifications/AutomaticBackupRetention/README.md @@ -0,0 +1,102 @@ +# Automatic backup retention + +This model checks atomic storage fallback and the authority to delete an old archive. +It separates enumeration, authentication, and deletion because each observes files +at a different time. + +## Source correspondence + +| Model state or action | Swift boundary | +| --- | --- | +| `CloudWriteStep`, `LocalWriteStep`, `copies` | `AutomaticBackupStorage.store` commits a file by atomic move; only a failed cloud write permits local fallback | +| `MaintenanceFailureStep` | Catalog or retention failure after a successful write does not trigger another export | +| `EnumerateStep`, `pending` | `catalog()` enumerates accessible roots and returns timestamp-ordered files | +| `ValidateStep`, `verified`, `hashes` | `reconcileRetention` selects the envelope key, authenticates the archive, and records its full-file digest | +| `Keepers`, `Candidates` | `AutomaticBackupRetention.prune` sorts authenticated timestamps and selects the newest three | +| `ChangeFileStep`, `revision` | A file changes after an earlier validation read | +| `ChangeAvailabilityStep` | The installation loses or regains access to its iCloud root | +| `PruneStep` | One coordinated operation verifies candidate and keeper digests before deleting the candidate | + +Valid file tokens are ordered by their authenticated export timestamp. +The model also includes an unknown-key file and a forged future-dated envelope. +Enumeration uses descending order, matching the catalog. This avoids exploring +irrelevant permutations of the same validation queue. + +## Safety properties + +- `PreserveUnknownFiles`: unknown keys and unrecognized files are not deleted. +- `PreserveChangedCandidates`: a changed candidate is not deleted using stale validation. +- `DeletionLeavesThreeHealthyFiles`: each deletion leaves at least three verified, accessible backups. +- `NoDuplicateFallback`: a committed cloud export is not also written locally. +- `TypeOK`: every model variable remains within its declared domain. + +The third property constrains the deletion action, not arbitrary external events. +External deletion or corruption can independently reduce the number of good files. +The application must not make that situation worse through stale pruning authority. + +## Counterexample and Swift correction + +The `review` control represents candidate-only revalidation from commit `0ae38ee1`. +Its shortest trace has 12 states: + +1. Commit a fourth valid backup. +2. Enumerate and authenticate all four backups. +3. Change one of the newest three files. +4. Recheck and delete the unchanged oldest file. + +Only two healthy backups remain. The previous implementation rechecked the +file being deleted, but did not recheck its replacements. + +`AutomaticBackupRetention.prune` now acquires the three keeper reads and candidate +deletion together through `NSFileAccessIntent`. It checks all four digests inside +that accessor. A missing or changed keeper prevents the deletion. +`AutomaticBackupRetentionTests` replays changed-keeper, missing-keeper, and changed-candidate cases. + +## Bounds and limitations + +The bounds are one or two installations, three initial healthy cloud archives, +one new export per installation, two untrusted files, and one external mutation. +Each installation can lose and regain cloud access once. +Local fallback belongs only to its installation. + +The passing cases check `EventuallySettled` under weak fairness for writes, +enumeration, validation, and deletion. File operations must eventually return. +They may fail. The model does not claim that iCloud always becomes available. +Explicit idle stuttering permits a live process after the bounded work finishes. + +The coordinated deletion action assumes a consistent local filesystem view. +It represents participating file coordinators on one system, not a distributed +transaction across iCloud replicas. Delayed replica deletions, remote conflict +resolution, uncoordinated external writes, and permanent disk loss are excluded. +The two-installation case exercises overlapping maintenance against this shared-view abstraction. +It does not prove globally exact three-file retention during a network partition. + +Encryption and ZIP validation are abstract predicates. Swift archive tests check +the real format. The separate catalog regressions check eviction preflight, +cancellation, and partial listings; this model does not emulate Foundation I/O. + +## Controls and results + +The checked candidate state spaces on 2026-09-07 were: + +| Case | Generated / distinct states | Depth | +| --- | ---: | ---: | +| `candidate` | 6,010 / 2,016 | 17 | +| `candidate-two-devices` | 11,133,064 / 2,661,340 | 34 | + +`unauthenticated` lets forged future-dated envelopes displace healthy files. +`retry-on-maintenance` writes a second copy after a cloud commit. +`stale-candidate` deletes a candidate after its bytes change. +Each negative control must violate its named invariant. +The reachability control must find an external mutation, a maintenance failure, +and an actual deletion in the same execution. + +## Run + +```sh +./tla-check AutomaticBackupRetention +``` + +See the [specification workflow](../README.md) for artifacts and pinned tools. +The larger case takes several minutes. Recheck the mapping after changing +coordination, fallback, authentication, or retention selection. diff --git a/Where/Specifications/AutomaticBackupRetention/Reachability.cfg b/Where/Specifications/AutomaticBackupRetention/Reachability.cfg new file mode 100644 index 000000000..ae280825b --- /dev/null +++ b/Where/Specifications/AutomaticBackupRetention/Reachability.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "candidate" + DeviceCount = 1 +INVARIANTS + TypeOK + CriticalStateNotReached +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/AutomaticBackupRetention/RetryOnMaintenance.cfg b/Where/Specifications/AutomaticBackupRetention/RetryOnMaintenance.cfg new file mode 100644 index 000000000..7d897a22d --- /dev/null +++ b/Where/Specifications/AutomaticBackupRetention/RetryOnMaintenance.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "retry-on-maintenance" + DeviceCount = 1 +INVARIANTS + TypeOK + NoDuplicateFallback +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/AutomaticBackupRetention/Review.cfg b/Where/Specifications/AutomaticBackupRetention/Review.cfg new file mode 100644 index 000000000..65d072f30 --- /dev/null +++ b/Where/Specifications/AutomaticBackupRetention/Review.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "review" + DeviceCount = 1 +INVARIANTS + TypeOK + DeletionLeavesThreeHealthyFiles +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/AutomaticBackupRetention/StaleCandidate.cfg b/Where/Specifications/AutomaticBackupRetention/StaleCandidate.cfg new file mode 100644 index 000000000..25cf081ae --- /dev/null +++ b/Where/Specifications/AutomaticBackupRetention/StaleCandidate.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "stale-candidate" + DeviceCount = 1 +INVARIANTS + TypeOK + PreserveChangedCandidates +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/AutomaticBackupRetention/Unauthenticated.cfg b/Where/Specifications/AutomaticBackupRetention/Unauthenticated.cfg new file mode 100644 index 000000000..e6f15c4b4 --- /dev/null +++ b/Where/Specifications/AutomaticBackupRetention/Unauthenticated.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "unauthenticated" + DeviceCount = 1 +INVARIANTS + TypeOK + DeletionLeavesThreeHealthyFiles +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/AutomaticBackupRetention/manifest.json b/Where/Specifications/AutomaticBackupRetention/manifest.json new file mode 100644 index 000000000..e9492040f --- /dev/null +++ b/Where/Specifications/AutomaticBackupRetention/manifest.json @@ -0,0 +1,46 @@ +{ + "source": "pluscal", + "module": "AutomaticBackupRetention.tla", + "cases": [ + { + "name": "candidate", + "config": "Candidate.cfg", + "expect": "pass" + }, + { + "name": "candidate-two-devices", + "config": "CandidateTwoDevices.cfg", + "expect": "pass" + }, + { + "name": "review", + "config": "Review.cfg", + "expect": "fail", + "outputContains": "Invariant DeletionLeavesThreeHealthyFiles is violated." + }, + { + "name": "unauthenticated", + "config": "Unauthenticated.cfg", + "expect": "fail", + "outputContains": "Invariant DeletionLeavesThreeHealthyFiles is violated." + }, + { + "name": "retry-on-maintenance", + "config": "RetryOnMaintenance.cfg", + "expect": "fail", + "outputContains": "Invariant NoDuplicateFallback is violated." + }, + { + "name": "stale-candidate", + "config": "StaleCandidate.cfg", + "expect": "fail", + "outputContains": "Invariant PreserveChangedCandidates is violated." + }, + { + "name": "reachability", + "config": "Reachability.cfg", + "expect": "fail", + "outputContains": "Invariant CriticalStateNotReached is violated." + } + ] +} diff --git a/Where/Specifications/README.md b/Where/Specifications/README.md index 513ec9140..ecee338a9 100644 --- a/Where/Specifications/README.md +++ b/Where/Specifications/README.md @@ -36,6 +36,17 @@ generated module. The run directory retains the module, TLC logs and state, and `summary.json`. The checker verifies that the tracked source's SHA-256 is unchanged before returning. +## Scheduled backup models + +The scheduled-backup models are documented separately: + +- [Automatic backup lifecycle](AutomaticBackupLifecycle/README.md): first unlock, cancellation ownership, commit, and retirement. +- [Recovery key publication](RecoveryKeyPublication/README.md): independent creation and append-only synchronization. +- [Automatic backup retention](AutomaticBackupRetention/README.md): storage fallback, authentication, and coordinated pruning. + +Run all three with `./tla-check AutomaticBackupLifecycle RecoveryKeyPublication AutomaticBackupRetention`. +Their READMEs state the bounds, controls, Swift correspondence, and limitations. + ## PlusCal migration result The nine raw-TLA models at commit `2a6c695bdb8d` on 2026-08-13 were checked diff --git a/Where/Specifications/RecoveryKeyPublication/Current.cfg b/Where/Specifications/RecoveryKeyPublication/Current.cfg new file mode 100644 index 000000000..fa54b36b5 --- /dev/null +++ b/Where/Specifications/RecoveryKeyPublication/Current.cfg @@ -0,0 +1,14 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "current" + DeviceCount = 2 +INVARIANTS + TypeOK + KeyPrecedesArchive + RecoveryKeysSurviveSync + PinnedKeyIsPreserved + NoCreationBeforeUnlock +PROPERTIES + EventuallyExported + EventuallySynchronized +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/RecoveryKeyPublication/CurrentThreeDevices.cfg b/Where/Specifications/RecoveryKeyPublication/CurrentThreeDevices.cfg new file mode 100644 index 000000000..160498900 --- /dev/null +++ b/Where/Specifications/RecoveryKeyPublication/CurrentThreeDevices.cfg @@ -0,0 +1,14 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "current" + DeviceCount = 3 +INVARIANTS + TypeOK + KeyPrecedesArchive + RecoveryKeysSurviveSync + PinnedKeyIsPreserved + NoCreationBeforeUnlock +PROPERTIES + EventuallyExported + EventuallySynchronized +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/RecoveryKeyPublication/MutableSlot.cfg b/Where/Specifications/RecoveryKeyPublication/MutableSlot.cfg new file mode 100644 index 000000000..10eca7dc6 --- /dev/null +++ b/Where/Specifications/RecoveryKeyPublication/MutableSlot.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "mutable-slot" + DeviceCount = 2 +INVARIANTS + TypeOK + RecoveryKeysSurviveSync +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/RecoveryKeyPublication/PublishFirst.cfg b/Where/Specifications/RecoveryKeyPublication/PublishFirst.cfg new file mode 100644 index 000000000..06431d5a1 --- /dev/null +++ b/Where/Specifications/RecoveryKeyPublication/PublishFirst.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "publish-first" + DeviceCount = 2 +INVARIANTS + TypeOK + KeyPrecedesArchive +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/RecoveryKeyPublication/README.md b/Where/Specifications/RecoveryKeyPublication/README.md new file mode 100644 index 000000000..1c1028e4a --- /dev/null +++ b/Where/Specifications/RecoveryKeyPublication/README.md @@ -0,0 +1,82 @@ +# Recovery key publication + +This model checks independent key creation, durable publication, and synchronization. +It asks whether synchronization can remove the last preserved secret for an +already published archive. + +## Source correspondence + +| Model state or action | Swift boundary | +| --- | --- | +| `unlocked` | The protected-data check before `BackupRecoveryKeyProvider.loadOrCreate` accesses Keychain | +| `ReadOrGenerateStep`, `chosen` | Read the installation's active key, or generate a candidate after an explicit missing-item result | +| `PreserveStep`, `ring` | `preserve(_:)` creates an immutable account in `KeychainCollection` | +| `PinStep`, `pin` | The non-synchronizing active-key item is created after preservation | +| `ExportStep`, `archives` | A returned recovery key is used by an automatic export | +| `SynchronizeStep` | Independent accounts arrive through iCloud Keychain without replacing another account | +| `CrashStep` | Process termination loses transient selection, but retains completed Keychain writes | +| `FailKeychainStep` | A failed preservation attempt returns without pinning or exporting | + +The model splits synchronous Keychain calls into separate atomic steps. +Other processes and synchronization can act between those calls, even though +one Swift actor does not interleave its synchronous method body. +An acknowledged Keychain write is treated as durable. + +`ring[d]` is the set currently known to installation `d`. +The local `pin[d]` is not a synchronized last-writer-wins register. +Cryptographic key bytes and account identifiers are represented by distinct tokens. + +## Properties and assumptions + +- `KeyPrecedesArchive`: the exporting installation preserved the key first. +- `RecoveryKeysSurviveSync`: every published archive's key remains in at least one durable collection. +- `PinnedKeyIsPreserved`: every active local key belongs to its local collection. +- `NoCreationBeforeUnlock`: neither preservation nor export occurs before unlock. +- `TypeOK`: every variable remains within its declared domain. + +The liveness properties require every installation to finish an export and +eventually learn all keys. They assume eventual unlock, successful Keychain +retries, and eventual synchronization between reachable devices. +Safety does not require prompt synchronization. The model permits arbitrary +finite delay before each synchronization action. + +The finite bounds are two or three installations, one candidate identity per +installation, at most one crash each, and at most one preservation failure each. +After a pre-pin crash, the model reuses that installation's abstract candidate. +It does not count abandoned random candidates or model local duplicate-item races. +The Swift tests cover duplicate creation, legacy-key preservation, malformed +items, and locked-item errors separately. + +Account deletion, account sign-out, device erasure, Keychain rollback, identifier +collisions, cryptographic failures, and permanent loss of every device are excluded. +The model does not promise recovery before synchronization completes. +A copied recovery key remains the user's independent recovery option. + +## Controls and results + +The checked current state spaces on 2026-09-07 were: + +| Case | Generated / distinct states | Depth | +| --- | ---: | ---: | +| `current` | 11,235 / 2,705 | 20 | +| `current-three-devices` | 6,743,923 / 863,441 | 30 | + +`mutable-slot` replaces collection union with destructive slot replacement. +It must lose a published key. `publish-first` exports before preservation and +must violate `KeyPrecedesArchive`. +These are protocol mutations, not simulations of all historical implementation details. + +The reachability control must find completed exports and synchronization after +both a crash and a failed Keychain attempt. +The existing Swift guard is +`BackupRecoveryKeyProviderTests.independentlyCreatedKeysRemainAvailableAfterSynchronization`. +No additional key-storage change was required by these bounded checks. + +## Run + +```sh +./tla-check RecoveryKeyPublication +``` + +See the [specification workflow](../README.md) for pinned tools and retained artifacts. +Recheck the mapping after changing preservation, active-key selection, or synchronization. diff --git a/Where/Specifications/RecoveryKeyPublication/Reachability.cfg b/Where/Specifications/RecoveryKeyPublication/Reachability.cfg new file mode 100644 index 000000000..1845013ec --- /dev/null +++ b/Where/Specifications/RecoveryKeyPublication/Reachability.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "current" + DeviceCount = 2 +INVARIANTS + TypeOK + CriticalStateNotReached +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/RecoveryKeyPublication/RecoveryKeyPublication.tla b/Where/Specifications/RecoveryKeyPublication/RecoveryKeyPublication.tla new file mode 100644 index 000000000..484ad7efd --- /dev/null +++ b/Where/Specifications/RecoveryKeyPublication/RecoveryKeyPublication.tla @@ -0,0 +1,112 @@ +---- MODULE RecoveryKeyPublication ---- +EXTENDS Integers, FiniteSets + +CONSTANTS Implementation, DeviceCount +ASSUME /\ Implementation \in {"current", "mutable-slot", "publish-first"} + /\ DeviceCount \in 2..3 +Devices == 1..DeviceCount +Keys == Devices +Phases == {"idle", "preserve", "pin", "export", "done"} + +(* --algorithm RecoveryKeyPublicationAlgorithm { +variables unlocked = FALSE, + phase = [d \in Devices |-> "idle"], + ring = [d \in Devices |-> {}], + pin = [d \in Devices |-> 0], + chosen = [d \in Devices |-> 0], + crashed = {}, + failed = {}, + archives = {}, + publishedWithoutKey = FALSE; + +fair process (Unlock = "unlock") { +UnlockStep: + while (TRUE) { await ~unlocked; unlocked := TRUE; } +} +fair process (ReadOrGenerate = "read") { +ReadOrGenerateStep: + while (TRUE) { + with (d \in Devices) { + await unlocked /\ phase[d] = "idle"; + chosen[d] := IF pin[d] # 0 THEN pin[d] ELSE d || + phase[d] := IF Implementation = "publish-first" THEN "export" ELSE "preserve"; + }; + } +} +fair process (Preserve = "preserve") { +PreserveStep: + while (TRUE) { + with (d \in Devices) { + await phase[d] = "preserve"; + ring[d] := IF Implementation = "mutable-slot" THEN {chosen[d]} + ELSE ring[d] \cup {chosen[d]} || + phase[d] := "pin"; + }; + } +} +fair process (Pin = "pin") { +PinStep: + while (TRUE) { + with (d \in Devices) { + await phase[d] = "pin"; + pin[d] := IF pin[d] = 0 THEN chosen[d] ELSE pin[d] || phase[d] := "export"; + }; + } +} +fair process (Export = "export") { +ExportStep: + while (TRUE) { + with (d \in Devices) { + await phase[d] = "export"; + archives := archives \cup {chosen[d]} || phase[d] := "done" || + publishedWithoutKey := publishedWithoutKey \/ chosen[d] \notin ring[d]; + }; + } +} +process (Crash = "crash") { +CrashStep: + while (TRUE) { + with (d \in Devices \ crashed) { + await phase[d] \in {"preserve", "pin", "export"}; + crashed := crashed \cup {d} || chosen[d] := 0 || phase[d] := "idle"; + }; + } +} +process (FailKeychain = "failure") { +FailKeychainStep: + while (TRUE) { + with (d \in Devices \ failed) { + await phase[d] = "preserve"; + failed := failed \cup {d} || chosen[d] := 0 || phase[d] := "idle"; + }; + } +} +fair process (Synchronize = "sync") { +SynchronizeStep: + while (TRUE) { + with (a \in Devices, b \in Devices) { + await a # b /\ ring[a] \ ring[b] # {}; + ring[b] := IF Implementation = "mutable-slot" THEN ring[a] ELSE ring[b] \cup ring[a]; + }; + } +} +process (Idle = "idle") { +IdleStep: + while (TRUE) { skip; } +} +} *) + +TypeOK == + /\ unlocked \in BOOLEAN /\ phase \in [Devices -> Phases] + /\ ring \in [Devices -> SUBSET Keys] + /\ pin \in [Devices -> (Keys \cup {0})] /\ chosen \in [Devices -> (Keys \cup {0})] + /\ crashed \subseteq Devices /\ failed \subseteq Devices /\ archives \subseteq Keys + /\ publishedWithoutKey \in BOOLEAN +KeyPrecedesArchive == ~publishedWithoutKey +RecoveryKeysSurviveSync == archives \subseteq UNION {ring[d] : d \in Devices} +PinnedKeyIsPreserved == \A d \in Devices : pin[d] = 0 \/ pin[d] \in ring[d] +NoCreationBeforeUnlock == ~unlocked => (UNION {ring[d] : d \in Devices} = {} /\ archives = {}) +EventuallyExported == <> (archives = Keys) +EventuallySynchronized == <> (\A d \in Devices : ring[d] = Keys) +CriticalStateNotReached == ~(archives = Keys /\ crashed # {} /\ failed # {} /\ \A d \in Devices : ring[d] = Keys) +==== diff --git a/Where/Specifications/RecoveryKeyPublication/manifest.json b/Where/Specifications/RecoveryKeyPublication/manifest.json new file mode 100644 index 000000000..a90150f0e --- /dev/null +++ b/Where/Specifications/RecoveryKeyPublication/manifest.json @@ -0,0 +1,34 @@ +{ + "source": "pluscal", + "module": "RecoveryKeyPublication.tla", + "cases": [ + { + "name": "current", + "config": "Current.cfg", + "expect": "pass" + }, + { + "name": "current-three-devices", + "config": "CurrentThreeDevices.cfg", + "expect": "pass" + }, + { + "name": "mutable-slot", + "config": "MutableSlot.cfg", + "expect": "fail", + "outputContains": "Invariant RecoveryKeysSurviveSync is violated." + }, + { + "name": "publish-first", + "config": "PublishFirst.cfg", + "expect": "fail", + "outputContains": "Invariant KeyPrecedesArchive is violated." + }, + { + "name": "reachability", + "config": "Reachability.cfg", + "expect": "fail", + "outputContains": "Invariant CriticalStateNotReached is violated." + } + ] +} diff --git a/Where/TODOs.md b/Where/TODOs.md index 42f2f4736..9495619a3 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -22,6 +22,7 @@ The item format and the placement rule live in the root - perf(WhereCore) [needs-design]: Performance pass — how often is the app booting? Can we only do it on changes of, say, 1 km or more? (human) ## P1s (Should do) +- test(Where): Validate encrypted automatic backups on signed devices before release. `Where/Sources/FirstUnlockAvailability.swift:25` probes Class C protection; simulator tests cannot prove reboot or passcode-lock behavior. With disposable data on two authorized devices, verify pre-first-unlock deferral, ordinary locked recording, background expiration, offline key creation followed by iCloud Keychain synchronization, restoration of each device's backup, and iCloud Drive download/fallback recovery. Record the device/OS versions and results in PR #306. Local unit tests cover the injected failure paths, not Apple's live transport or hardware protection. (pr#306 review, 2026-09-06) - fix(WhereCore) [needs-design]: Scope initial CloudKit-import readiness to Where's expected store/container. `CloudKitImportReadiness.start()` observes `NSPersistentCloudKitContainer.eventChangedNotification` with `object: nil` (`WhereCore/Sources/Persistence/CloudKitImportReadiness.swift:19-26`), and `eventChanged(_:)` accepts any successful completed import (`:42-49`), while discovery starts that observer at `WhereUI/Sources/Launch/WhereLaunch.swift:327` — two lines before `prepareStore()` creates the intended store at `:329`. An unrelated CloudKit-backed store in the process could therefore release onboarding against an incomplete device list. Bind readiness to the container/store created for this launch (or return its initial-import completion directly from store preparation), ignore unrelated notifications, and cover that filtering with tests. (pr#160 review) - refactor(WhereUI) [quick-win]: Remove `StoredContext.CodingKeys`; it lists every property under the identical synthesized key and the installation-context sidecar has no shipped compatibility shape to preserve (`WhereUI/Sources/Launch/InstallationRecordingContextStore.swift:148-158`). Let the compiler synthesize the keys and retain the existing persistence round-trip coverage as the wire-shape guard. (pr#160 review) - feat(Where) [needs-design]: Add an optional onboarding step that backfills the current year from the GPS metadata of photos in the user's library. `OnboardingView.Phase` currently moves from region selection/customization directly to location permission (`WhereUI/Sources/Onboarding/OnboardingView.swift:30`), while `DayJournal.ingest(_:)` is the existing bulk sample path (`WhereCore/Sources/Journal/DayJournal.swift:82`). Design a PhotoKit-backed importer that requests access only after an explicit opt-in, reads location and capture time locally without uploading photo contents, previews what will be added, records photo-derived provenance rather than treating it as live GPS, deduplicates repeat imports, and makes skipping the screen frictionless. (human 2026-08-03) diff --git a/Where/Where/AGENTS.md b/Where/Where/AGENTS.md index 024585c6e..0ef0c14ca 100644 --- a/Where/Where/AGENTS.md +++ b/Where/Where/AGENTS.md @@ -51,6 +51,16 @@ layering, and the domain rules this target merely starts up. `initializePrerequisites` installs the `CLLocationManager` in time to receive the queued event). It hands it to `RootView` through `WhereApp`. Do not move this wiring into a view. +- **Register and submit automatic-backup background work only through the app adapter.** + Probe first-unlock availability before opening the store or querying Keychain. + Put installation-sidecar loading behind the same barrier in the shared + launch plan; RootView promotion must not bypass it (`FirstUnlockAvailabilityTests`). + Keep construction and preparation at this owner; Bumper's installation-context + rules reject competing owners (`.bumper/Tests/WhereProjectRulesTests.swift`). + Do not gate ordinary locked recording on `isProtectedDataAvailable` + (`FirstUnlockAvailabilityTests`). Keep backups outside the launch trunk; + expiration must cancel the shared export, not only its waiter + (`AutomaticBackupLaunchReadinessTests` / `AutomaticBackupServiceTests`). - **Reconcile reporting before forwarding launch to the selected runtime.** Snapshot crash and replay choices once. Use the same process preferences for `WhereModel`. Never start the provider on an all-Off launch. Remote-log sink diff --git a/Where/Where/README.md b/Where/Where/README.md index c070d5f80..60b0c0e78 100644 --- a/Where/Where/README.md +++ b/Where/Where/README.md @@ -1,5 +1,13 @@ # Where (app target) +Automatic backups run outside the recording launch trunk. Background expiration +cancels their shared operation without cancelling recording launch. A Class C +file probe distinguishes first-unlock protection from an ordinary screen lock. +The shared launch plan waits for that probe before loading the installation +sidecar. Construction does no sidecar I/O. Protected-data availability resumes +headless and UI-driven waiters through the same barrier. +The handler exits at an unanswered onboarding gate and retries after unlock. + The iOS/iPadOS app bundle for **Where**. It is deliberately a shell. It starts the process, builds the objects everything else shares, and shows `WhereUI`'s `RootView`. @@ -29,7 +37,7 @@ In release this is always `RegularApplicationRuntime`. In DEBUG, `WhereDeveloperLaunchController` persists one mutually exclusive next-process choice: the standalone Inspector runtime or a configured, one-shot demo inside the regular runtime. The demo request is consumed as the -regular runtime is built. Its first launch step activates an in-memory scope +regular runtime is built. After first-unlock preparation, its demo step activates an in-memory scope before onboarding can open the real store. A later process therefore returns to the user's untouched data unless another demo is scheduled. Every later callback and root-view request uses protocol dispatch, so no feature or lifecycle code switches on a mode. diff --git a/Where/Where/Sources/AppDelegate.swift b/Where/Where/Sources/AppDelegate.swift index fabd0bcd2..cbfd76828 100644 --- a/Where/Where/Sources/AppDelegate.swift +++ b/Where/Where/Sources/AppDelegate.swift @@ -138,4 +138,8 @@ final class AppDelegate: NSObject, UIApplicationDelegate { } return runtime.didFinishLaunching(application: application, options: options) } + + func applicationProtectedDataDidBecomeAvailable(_: UIApplication) { + runtime.protectedDataDidBecomeAvailable() + } } diff --git a/Where/Where/Sources/AutomaticBackupBackgroundScheduler.swift b/Where/Where/Sources/AutomaticBackupBackgroundScheduler.swift new file mode 100644 index 000000000..169b715a1 --- /dev/null +++ b/Where/Where/Sources/AutomaticBackupBackgroundScheduler.swift @@ -0,0 +1,71 @@ +import BackgroundTasks +import Foundation +import os +import WhereCore + +/// `BGProcessingTask` adapter for Core's scheduling seam. Registration stays +/// in the app target because only the application owns lifecycle callbacks. +@MainActor +final class AutomaticBackupBackgroundScheduler: AutomaticBackupTaskScheduling { + static let identifier = "com.stuff.where.automatic-backup" + + private let scheduler: BGTaskScheduler + private let logger = Logger(subsystem: "com.stuff.where", category: "AutomaticBackup") + private var isRegistered = false + + init(scheduler: BGTaskScheduler = .shared) { + self.scheduler = scheduler + } + + func register(handler: @escaping @MainActor @Sendable () async -> Bool) { + guard !isRegistered else { return } + isRegistered = scheduler.register( + forTaskWithIdentifier: Self.identifier, + using: nil, + ) { task in + guard let processingTask = task as? BGProcessingTask else { + task.setTaskCompleted(success: false) + return + } + Task { @MainActor in + let operation = Task { @MainActor in await handler() } + processingTask.expirationHandler = { operation.cancel() } + let succeeded = await operation.value + processingTask.setTaskCompleted(success: succeeded && !operation.isCancelled) + } + } + if !isRegistered { + logger.error("BGTask registration failed") + } + } + + nonisolated func reconcile(isEnabled: Bool, earliestBeginDate: Date?) async { + await reconcileOnMainActor( + isEnabled: isEnabled, + earliestBeginDate: earliestBeginDate, + ) + } + + private func reconcileOnMainActor(isEnabled: Bool, earliestBeginDate: Date?) { + scheduler.cancel(taskRequestWithIdentifier: Self.identifier) + guard isEnabled else { return } + + let request = BGProcessingTaskRequest(identifier: Self.identifier) + request.earliestBeginDate = earliestBeginDate + request.requiresNetworkConnectivity = false + request.requiresExternalPower = false + do { + try scheduler.submit(request) + } catch { + logger + .error("BGTask submission failed: \(error.localizedDescription, privacy: .public)") + } + } + + func retryAfterFirstUnlock() { + reconcileOnMainActor( + isEnabled: true, + earliestBeginDate: Date().addingTimeInterval(15 * 60), + ) + } +} diff --git a/Where/Where/Sources/AutomaticBackupLaunchReadiness.swift b/Where/Where/Sources/AutomaticBackupLaunchReadiness.swift new file mode 100644 index 000000000..0a4adc93a --- /dev/null +++ b/Where/Where/Sources/AutomaticBackupLaunchReadiness.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Waits only for launch progress, never for user interaction. Cancellation +/// stops this waiter without cancelling the shared recording launch. +@MainActor +enum AutomaticBackupLaunchReadiness { + enum State { + case pending + case ready + case unavailable + } + + static func wait(readState: () -> State) async throws -> Bool { + while true { + try Task.checkCancellation() + switch readState() { + case .ready: return true + case .unavailable: return false + case .pending: try await Task.sleep(for: .milliseconds(50)) + } + } + } +} diff --git a/Where/Where/Sources/FirstUnlockAvailability.swift b/Where/Where/Sources/FirstUnlockAvailability.swift new file mode 100644 index 000000000..415cc8ef3 --- /dev/null +++ b/Where/Where/Sources/FirstUnlockAvailability.swift @@ -0,0 +1,101 @@ +import Foundation +import os +import UIKit + +/// Probes Class C file protection without opening the data store or querying +/// Keychain. Unlike UIApplication's flag, Class C remains available on relock. +actor FirstUnlockAvailability { + private let marker: URL + private let isDeviceUnlocked: @Sendable () async -> Bool + private var hasBeenAvailable = false + private var waiters: [UUID: CheckedContinuation] = [:] + private let logger = Logger(subsystem: "com.stuff.where", category: "AutomaticBackup") + + init(marker: URL, isDeviceUnlocked: @escaping @Sendable () async -> Bool) { + self.marker = marker + self.isDeviceUnlocked = isDeviceUnlocked + } + + static func applicationSupport() -> FirstUnlockAvailability { + FirstUnlockAvailability(marker: URL.applicationSupportDirectory + .appendingPathComponent("Where/first-unlock-probe", isDirectory: false)) + { + await MainActor.run { UIApplication.shared.isProtectedDataAvailable } + } + } + + func isAvailable() async -> Bool { + if hasBeenAvailable { return true } + let unlocked = await isDeviceUnlocked() + do { + if !FileManager.default.fileExists(atPath: marker.path) { + try FileManager.default.createDirectory( + at: marker.deletingLastPathComponent(), + withIntermediateDirectories: true, + ) + try Data([1]).write(to: marker, options: [ + .completeFileProtectionUntilFirstUserAuthentication, + .withoutOverwriting, + ]) + } + _ = try Data(contentsOf: marker) + protectedDataDidBecomeAvailable() + return true + } catch { + if unlocked { + logger + .warning( + "First-unlock probe unavailable: \(error.localizedDescription, privacy: .public)", + ) + } + // The system's unlocked state is authoritative; never block + // recording because creating the marker failed (for example ENOSPC). + if unlocked { protectedDataDidBecomeAvailable() } + return hasBeenAvailable + } + } + + /// All launch entry points join this barrier, including RootView promotion. + func waitUntilAvailable() async throws { + if await isAvailable() { + try Task.checkCancellation() + return + } + let token = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + Void, + Error + >) in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + } else if hasBeenAvailable { + continuation.resume() + } else { + waiters[token] = continuation + } + } + } onCancel: { + Task { await self.cancelWaiter(token) } + } + } + + func protectedDataDidBecomeAvailable() { + hasBeenAvailable = true + let pending = waiters.values + waiters.removeAll() + for continuation in pending { + continuation.resume() + } + } + + private func cancelWaiter(_ token: UUID) { + waiters.removeValue(forKey: token)?.resume(throwing: CancellationError()) + } + + #if DEBUG + @_spi(Testing) public var waitingCallerCount: Int { + waiters.count + } + #endif +} diff --git a/Where/Where/Sources/RegularApplicationRuntime.swift b/Where/Where/Sources/RegularApplicationRuntime.swift index 68cc91b05..2e07a4b0e 100644 --- a/Where/Where/Sources/RegularApplicationRuntime.swift +++ b/Where/Where/Sources/RegularApplicationRuntime.swift @@ -1,5 +1,6 @@ import AppIntents import LifecycleKit +import os import PeriscopeCore import SwiftUI import UIKit @@ -19,6 +20,12 @@ final class RegularApplicationRuntime: WhereApplicationRuntime { let intentServices = IntentServices() private let widgetPresentationPublisher = WidgetPresentationPublisher() private(set) var launcher: LifecycleRunner! + private let automaticBackupScheduler: AutomaticBackupBackgroundScheduler + private let backupRecoveryKeys: BackupRecoveryKeyProvider + private let firstUnlockAvailability: FirstUnlockAvailability + private let installationContextStore = FileInstallationRecordingContextStore() + private var launchBackupClaimedByBackgroundTask = false + private let logger = Logger(subsystem: "com.stuff.where", category: "AutomaticBackup") #if DEBUG /// Compiled into Debug device builds created by `Where/install --cloudkit`, so every @@ -39,14 +46,25 @@ final class RegularApplicationRuntime: WhereApplicationRuntime { applyRemoteLogging: @escaping DiagnosticReportingSettingsModel.ApplyRemoteLogging, developerLaunchController: WhereDeveloperLaunchController? = nil, ) { + let automaticBackupScheduler = AutomaticBackupBackgroundScheduler() + let firstUnlockAvailability = FirstUnlockAvailability.applicationSupport() + self.firstUnlockAvailability = firstUnlockAvailability + let backupRecoveryKeys = BackupRecoveryKeyProvider.system { + await firstUnlockAvailability.isAvailable() + } + self.automaticBackupScheduler = automaticBackupScheduler + self.backupRecoveryKeys = backupRecoveryKeys self.developerLaunchController = developerLaunchController model = Self.makeModel( + installationContextStore: installationContextStore, storeStorage: Self.storeStorage( forCloudKitValidationBuild: Self.isCloudKitValidationBuild, ), preferences: preferences, effectiveDiagnosticReportingConfiguration: effectiveDiagnosticReportingConfiguration, applyRemoteLogging: applyRemoteLogging, + automaticBackupScheduler: automaticBackupScheduler, + backupRecoveryKeys: backupRecoveryKeys, ) if let configuration = developerLaunchController?.consumeDemoConfiguration() { model.prepareDemoLaunch(configuration: configuration) @@ -64,22 +82,35 @@ final class RegularApplicationRuntime: WhereApplicationRuntime { effectiveDiagnosticReportingConfiguration: DiagnosticReportingConfiguration, applyRemoteLogging: @escaping DiagnosticReportingSettingsModel.ApplyRemoteLogging, ) { + let automaticBackupScheduler = AutomaticBackupBackgroundScheduler() + let firstUnlockAvailability = FirstUnlockAvailability.applicationSupport() + self.firstUnlockAvailability = firstUnlockAvailability + let backupRecoveryKeys = BackupRecoveryKeyProvider.system { + await firstUnlockAvailability.isAvailable() + } + self.automaticBackupScheduler = automaticBackupScheduler + self.backupRecoveryKeys = backupRecoveryKeys model = Self.makeModel( + installationContextStore: installationContextStore, storeStorage: .cloudKit, preferences: preferences, effectiveDiagnosticReportingConfiguration: effectiveDiagnosticReportingConfiguration, applyRemoteLogging: applyRemoteLogging, + automaticBackupScheduler: automaticBackupScheduler, + backupRecoveryKeys: backupRecoveryKeys, ) } #endif private static func makeModel( + installationContextStore: FileInstallationRecordingContextStore, storeStorage: SwiftDataStore.Storage, preferences: WherePreferences, effectiveDiagnosticReportingConfiguration: DiagnosticReportingConfiguration, applyRemoteLogging: @escaping DiagnosticReportingSettingsModel.ApplyRemoteLogging, + automaticBackupScheduler: AutomaticBackupBackgroundScheduler, + backupRecoveryKeys: BackupRecoveryKeyProvider, ) -> WhereModel { - let installationContextStore = FileInstallationRecordingContextStore() let locationOutbox = FileLocationOutbox.applicationSupport() return WhereModel( preferences: preferences, @@ -89,6 +120,9 @@ final class RegularApplicationRuntime: WhereApplicationRuntime { installationContextStore: $0, storeStorage: storeStorage, locationOutbox: locationOutbox, + backupRecoveryKeys: backupRecoveryKeys, + automaticBackupStorage: AutomaticBackupStorage(), + automaticBackupScheduler: automaticBackupScheduler, ) }, logSystem: .shared, @@ -103,6 +137,10 @@ final class RegularApplicationRuntime: WhereApplicationRuntime { ) -> Bool { AppDependencyManager.shared .add(dependency: { [intentServices = self.intentServices] in intentServices }) + automaticBackupScheduler.register { [weak self] in + await self?.performBackgroundBackup() ?? false + } + Task { [weak self] in await self?.initializeRecoveryKey() } WhereLaunch.startAmbientLogging(on: .shared) model.onLoggedOut = { [intentServices] in await intentServices.clear() } @@ -113,21 +151,105 @@ final class RegularApplicationRuntime: WhereApplicationRuntime { } model.synchronizeTheme() let launcher = WhereLaunch - .makeLauncher(model: model, reason: .undetermined) { [intentServices, model] in + .makeLauncher( + model: model, + reason: .undetermined, + prepareProtectedData: { [firstUnlockAvailability, installationContextStore] in + try await firstUnlockAvailability.waitUntilAvailable() + try installationContextStore.prepareAfterFirstUnlock() + }, + ) { [intentServices, model] in await intentServices.install( .forIntents(sharingStoreOf: $0), theme: model.theme, ) } self.launcher = launcher - Task { [launcher, model, intentServices] in - await launcher.run() - guard !model.isInDemoMode else { return } - await RegionSpotlightIndexer.indexRegions(resolving: intentServices) + Task { [weak self] in + guard await self?.firstUnlockAvailability.isAvailable() == true else { + return + } + await self?.driveLaunch() } return true } + func protectedDataDidBecomeAvailable() { + Task { [weak self] in + await self?.firstUnlockAvailability.protectedDataDidBecomeAvailable() + await self?.initializeRecoveryKey() + await self?.driveLaunch() + if self?.launchBackupClaimedByBackgroundTask == true { + await self?.model.session?.runAutomaticBackupIfDue() + } + } + } + + private func driveLaunch() async { + await launcher.run() + guard !model.isInDemoMode else { return } + // The launcher owns recording, not backup execution. Keep the export + // out of its unstructured trunk so expiration can cancel the real job. + if !launchBackupClaimedByBackgroundTask { + await model.session?.runAutomaticBackupIfDue() + } + await RegionSpotlightIndexer.indexRegions(resolving: intentServices) + } + + private func initializeRecoveryKey() async { + do { + _ = try await backupRecoveryKeys.loadOrCreate() + } catch BackupRecoveryKeyProvider.ProviderError.deferredUntilFirstUnlock { + // Expected for a background launch before the first unlock. + } catch { + logger.error( + "Recovery-key initialization failed: \(error.localizedDescription, privacy: .public)", + ) + } + } + + private func performBackgroundBackup() async -> Bool { + launchBackupClaimedByBackgroundTask = true + guard await firstUnlockAvailability.isAvailable() else { + // Crucially, do not drive the launcher: resolving the scope would + // open the store before first unlock. + automaticBackupScheduler.retryAfterFirstUnlock() + return false + } + + // didFinishLaunching already drives the shared launch. Do not await + // its unstructured task or park behind an unanswered onboarding gate. + do { + let isReady = try await AutomaticBackupLaunchReadiness.wait { + switch launcher.phase { + case .awaitingGate, .failed: .unavailable + case .launching, .running: .pending + case .ready: .ready + } + } + guard isReady else { + await automaticBackupScheduler.reconcile(isEnabled: false, earliestBeginDate: nil) + return false + } + try Task.checkCancellation() + } catch { + await model.activeScope?.services.automaticBackups?.cancelCurrentRun() + if model.session == nil { automaticBackupScheduler.retryAfterFirstUnlock() } + return false + } + guard let result = await model.session?.runAutomaticBackupIfDue() else { + if model.session == nil { automaticBackupScheduler.retryAfterFirstUnlock() } + return false + } + switch result { + case .disabled, .notDue, .alreadyRunning, .completed: + return !Task.isCancelled + case .deferredUntilFirstUnlock: + automaticBackupScheduler.retryAfterFirstUnlock() + return false + } + } + func makeRootView() -> AnyView { #if DEBUG AnyView(RootView( diff --git a/Where/Where/Sources/WhereApplicationRuntime.swift b/Where/Where/Sources/WhereApplicationRuntime.swift index c30bd0832..42c16da71 100644 --- a/Where/Where/Sources/WhereApplicationRuntime.swift +++ b/Where/Where/Sources/WhereApplicationRuntime.swift @@ -15,4 +15,9 @@ protocol WhereApplicationRuntime: AnyObject { ) -> Bool func makeRootView() -> AnyView + func protectedDataDidBecomeAvailable() +} + +extension WhereApplicationRuntime { + func protectedDataDidBecomeAvailable() {} } diff --git a/Where/Where/Tests/AutomaticBackupLaunchReadinessTests.swift b/Where/Where/Tests/AutomaticBackupLaunchReadinessTests.swift new file mode 100644 index 000000000..facc34307 --- /dev/null +++ b/Where/Where/Tests/AutomaticBackupLaunchReadinessTests.swift @@ -0,0 +1,16 @@ +import Testing +@testable import Where + +@MainActor +struct AutomaticBackupLaunchReadinessTests { + @Test func onboardingOrFailedLaunchDoesNotParkTheBackgroundHandler() async throws { + #expect(try await AutomaticBackupLaunchReadiness.wait { .unavailable } == false) + #expect(try await AutomaticBackupLaunchReadiness.wait { .ready }) + } + + @Test func expirationStopsAWaitForLaunch() async { + let operation = Task { try await AutomaticBackupLaunchReadiness.wait { .pending } } + operation.cancel() + await #expect(throws: CancellationError.self) { try await operation.value } + } +} diff --git a/Where/Where/Tests/FirstUnlockAvailabilityTests.swift b/Where/Where/Tests/FirstUnlockAvailabilityTests.swift new file mode 100644 index 000000000..4738e3d0f --- /dev/null +++ b/Where/Where/Tests/FirstUnlockAvailabilityTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@_spi(Testing) @testable import Where + +struct FirstUnlockAvailabilityTests { + @Test(.timeLimit(.minutes(1))) + func unlockResumesAllLaunchWaitersWithoutReopeningOnOrdinaryRelock() async throws { + let file = URL.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data([1]).write(to: file) + defer { try? FileManager.default.removeItem(at: file) } + let availability = FirstUnlockAvailability( + marker: file.appendingPathComponent("inaccessible"), + isDeviceUnlocked: { false }, + ) + let headless = Task { try await availability.waitUntilAvailable() } + let foreground = Task { try await availability.waitUntilAvailable() } + defer { headless.cancel(); foreground.cancel() } + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while await availability.waitingCallerCount < 2, ContinuousClock.now < deadline { + await Task.yield() + } + try #require(await availability.waitingCallerCount == 2) + await availability.protectedDataDidBecomeAvailable() + try await headless.value + try await foreground.value + #expect(await availability.waitingCallerCount == 0) + #expect(await availability.isAvailable()) + try await availability.waitUntilAvailable() + } + + @Test(.timeLimit(.minutes(1))) + func cancelledLaunchWaiterDoesNotOpenOrBlockTheBarrier() async throws { + let file = URL.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data([1]).write(to: file) + defer { try? FileManager.default.removeItem(at: file) } + let availability = FirstUnlockAvailability( + marker: file.appendingPathComponent("inaccessible"), + isDeviceUnlocked: { false }, + ) + let waiter = Task { try await availability.waitUntilAvailable() } + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while await availability.waitingCallerCount == 0, ContinuousClock.now < deadline { + await Task.yield() + } + try #require(await availability.waitingCallerCount == 1) + waiter.cancel() + await #expect(throws: CancellationError.self) { try await waiter.value } + #expect(await availability.waitingCallerCount == 0) + #expect(await availability.isAvailable() == false) + } + + @Test func anAccessibleClassCMarkerAllowsAnOrdinaryLockedLaunch() async throws { + let root = URL.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let marker = root.appendingPathComponent("marker") + try Data([1]).write( + to: marker, + options: .completeFileProtectionUntilFirstUserAuthentication, + ) + let availability = FirstUnlockAvailability(marker: marker, isDeviceUnlocked: { false }) + #expect(await availability.isAvailable()) + } + + @Test(arguments: [true, false]) + func anUnreadableProbeOnlyAllowsAnExplicitlyUnlockedDevice(unlocked: Bool) async throws { + let file = URL.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data([1]).write(to: file) + defer { try? FileManager.default.removeItem(at: file) } + let availability = FirstUnlockAvailability( + marker: file.appendingPathComponent("cannot-exist"), + isDeviceUnlocked: { unlocked }, + ) + #expect(await availability.isAvailable() == unlocked) + } +} diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index f4d8fda8a..2385fb419 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -58,6 +58,26 @@ internal shape. lossless.** Add persisted user-data shapes end-to-end and cover both import strategies. Export no target-owned recording check-ins. Ignore any in an imported archive (`BackupServiceTests` / `BackupCoordinatorTests`). +- **Keep manual exports plaintext and automatic backups encrypted.** Wrap the unchanged backup ZIP + in the authenticated `.wherebackup` envelope; never create a replacement recovery key while + protected data or the Keychain item is inaccessible (`EncryptedBackupEnvelopeTests` / + `BackupRecoveryKeyProviderTests`). +- **Preserve recovery secrets under immutable synchronized identifiers.** Keep + the active key local. Select restore keys by envelope identifier and never + persist an entered key (`BackupRecoveryKeyProviderTests`). +- **Authenticate archives before counting them toward retention.** Preserve + unknown keys and invalid files. Do not repeat a committed write because + retention failed (`AutomaticBackupStorageTests`). + Recheck the candidate and all retained archive digests in one coordinated + operation before deleting (`AutomaticBackupRetentionTests`). +- **Own automatic execution outside the launch trunk.** Cancel and drain it + before reset or logout. Reconcile scheduling from the latest configuration + (`AutomaticBackupServiceTests`). `CoordinatedBackupFileAccess` may send only + `NSFileCoordinator.cancel()` across threads, as permitted by Apple's contract. + Give UI callers no cancellation authority over the shared export. +- **Keep catalog I/O outside the storage actor.** Preflight download status + before content coordination. Cancel pending reads with their owning view and + preserve accessible entries when iCloud is partial (`AutomaticBackupStorageTests`). - **Backup import never adopts or changes local recording consent.** Archives omit that device-local choice. Replace preserves it and every existing removal tombstone while rotating the data generation and discarding the local diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 48d3eb53c..bffb2c6a0 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -8,6 +8,38 @@ CoreLocation — **no SwiftUI or UIKit** — so all of it is unit-testable off-s [`RegionKit`](../RegionKit) for coordinate→region lookup and logs through [`Periscope`](../../Shared/Periscope) via the `WhereLog` facade. +Automatic backups keep the existing inner backup schema intact, then encrypt +that ZIP with AES-256-GCM inside a `.wherebackup` envelope. Each installation +keeps a local active key. An append-only Keychain collection synchronizes keys +by identifier, so simultaneous offline creation cannot replace another key. +Restore selects the envelope's exact key. The original single-account key is +preserved when available. Entered recovery keys remain ephemeral. + +`AutomaticBackupStorage` prefers iCloud Drive and falls back to app Documents. +It coordinates each archive access and retains the newest three authenticated, +readable backups with available keys. Unknown or damaged files cannot displace +recoverable backups. Files with unavailable keys remain untouched. +Deletion coordinates the candidate and all three retained files together. +Changed or unavailable retained files prevent deletion until a later scan. +Manual exports remain plaintext ZIPs. + +Catalog reads run outside the storage actor with independent cancellation. +They check download metadata before requesting content access, so evicted iCloud +files produce a partial listing without hiding accessible cloud or local files. + +The service owns one cancellable operation shared by all triggers. Callers +choose cancellation authority explicitly. Background expiration cancels execution; +Data-page disappearance waits for its result without cancelling the export. +The UI caller still records successful metadata before returning. The service uses +the latest preferences when scheduling. Reset suspends and drains the operation; +a failed reset resumes scheduling. Logout permanently retires that service. +Preference reset generations reject late success metadata from retired sessions. +ZIP progress and pending file coordination receive cancellation. A committed +backup remains successful if later retention fails; subsequent runs retry cleanup. + +Bounded [backup specifications](../Specifications/README.md) check these lifecycle, +key-publication, and retention protocols. Their READMEs define the proof boundaries. + Everything is reached through one `Sendable` container, **`WhereServices`**, which the presentation layer (`WhereUI`) and the widget extension talk to. For the domain/presentation layering and the rules this module enforces, see the diff --git a/Where/WhereCore/Sources/Backup/AutomaticBackupFile.swift b/Where/WhereCore/Sources/Backup/AutomaticBackupFile.swift new file mode 100644 index 000000000..d9258d873 --- /dev/null +++ b/Where/WhereCore/Sources/Backup/AutomaticBackupFile.swift @@ -0,0 +1,47 @@ +import Foundation + +public struct AutomaticBackupFile: Identifiable, Hashable, Sendable { + public enum StorageLocation: String, Codable, Hashable, Sendable { + case iCloudDrive + case appDocuments + } + + public enum Protection: String, Codable, Hashable, Sendable { + case aesGCM256 = "aes-gcm-256" + case plaintext + } + + public let url: URL + public let exportedAt: Date + public let byteCount: Int64? + public let storageLocation: StorageLocation + public let protection: Protection + + public var id: URL { + url + } + + public init( + url: URL, + exportedAt: Date, + byteCount: Int64?, + storageLocation: StorageLocation, + protection: Protection, + ) { + self.url = url + self.exportedAt = exportedAt + self.byteCount = byteCount + self.storageLocation = storageLocation + self.protection = protection + } +} + +public struct AutomaticBackupCatalog: Hashable, Sendable { + public let files: [AutomaticBackupFile] + public let isICloudUnavailable: Bool + + public init(files: [AutomaticBackupFile], isICloudUnavailable: Bool) { + self.files = files + self.isICloudUnavailable = isICloudUnavailable + } +} diff --git a/Where/WhereCore/Sources/Backup/AutomaticBackupFileAvailability.swift b/Where/WhereCore/Sources/Backup/AutomaticBackupFileAvailability.swift new file mode 100644 index 000000000..86e41b21a --- /dev/null +++ b/Where/WhereCore/Sources/Backup/AutomaticBackupFileAvailability.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Metadata-only preflight. Catalog reads must not download evicted iCloud files. +public protocol AutomaticBackupFileAvailabilityChecking: Sendable { + func isDownloaded(at url: URL) throws -> Bool +} + +public struct SystemAutomaticBackupFileAvailability: AutomaticBackupFileAvailabilityChecking { + public init() {} + + public func isDownloaded(at url: URL) throws -> Bool { + try url.resourceValues(forKeys: [.ubiquitousItemDownloadingStatusKey]) + .ubiquitousItemDownloadingStatus != .notDownloaded + } +} diff --git a/Where/WhereCore/Sources/Backup/AutomaticBackupInterval.swift b/Where/WhereCore/Sources/Backup/AutomaticBackupInterval.swift new file mode 100644 index 000000000..6fc1181e8 --- /dev/null +++ b/Where/WhereCore/Sources/Backup/AutomaticBackupInterval.swift @@ -0,0 +1,18 @@ +import Foundation + +/// The user-selected cadence for automatic backups. A scheduled date is the +/// earliest eligible time; the system may run the work later. +public enum AutomaticBackupInterval: String, CaseIterable, Codable, Sendable { + case daily + case weekly + case monthly + + public func nextDate(after date: Date, calendar: Calendar = .current) -> Date { + let component = switch self { + case .daily: DateComponents(day: 1) + case .weekly: DateComponents(day: 7) + case .monthly: DateComponents(month: 1) + } + return calendar.date(byAdding: component, to: date) ?? date + } +} diff --git a/Where/WhereCore/Sources/Backup/AutomaticBackupRetention.swift b/Where/WhereCore/Sources/Backup/AutomaticBackupRetention.swift new file mode 100644 index 000000000..6402d71ba --- /dev/null +++ b/Where/WhereCore/Sources/Backup/AutomaticBackupRetention.swift @@ -0,0 +1,43 @@ +import CryptoKit +import Foundation + +/// Authentication results from one retention scan. A deletion is authorized +/// only while its candidate and all three retained archives still match. +struct AutomaticBackupRetention { + struct VerifiedFile { + let file: AutomaticBackupFile + let digest: SHA256.Digest + let exportedAt: Date + } + + let verified: [VerifiedFile] + let retainedFileCount: Int + + func prune() async throws { + precondition(retainedFileCount > 0) + let ordered = verified.sorted { + if $0.exportedAt == $1.exportedAt { return $0.file.url.path < $1.file.url.path } + return $0.exportedAt > $1.exportedAt + } + let keepers = Array(ordered.prefix(retainedFileCount)) + for candidate in ordered.dropFirst(retainedFileCount) { + try Task.checkCancellation() + try await CoordinatedBackupFileAccess.delete( + at: candidate.file.url, + keeping: keepers.map(\.file.url), + ) { candidateURL, keeperURLs, progress in + for (keeper, url) in zip(keepers, keeperURLs) { + if progress.isCancelled { throw CancellationError() } + guard try Self.digest(at: url) == keeper.digest else { return } + } + guard try Self.digest(at: candidateURL) == candidate.digest else { return } + if progress.isCancelled { throw CancellationError() } + try FileManager.default.removeItem(at: candidateURL) + } + } + } + + private static func digest(at url: URL) throws -> SHA256.Digest { + try SHA256.hash(data: Data(contentsOf: url, options: .mappedIfSafe)) + } +} diff --git a/Where/WhereCore/Sources/Backup/AutomaticBackupService.swift b/Where/WhereCore/Sources/Backup/AutomaticBackupService.swift new file mode 100644 index 000000000..365bcae92 --- /dev/null +++ b/Where/WhereCore/Sources/Backup/AutomaticBackupService.swift @@ -0,0 +1,289 @@ +import Foundation + +public struct AutomaticBackupConfiguration: Sendable, Equatable { + public let isEnabled: Bool + public let isRecordingEnabled: Bool + public let interval: AutomaticBackupInterval + public let lastSuccessfulBackupAt: Date? + + public init( + isEnabled: Bool, + isRecordingEnabled: Bool, + interval: AutomaticBackupInterval, + lastSuccessfulBackupAt: Date?, + ) { + self.isEnabled = isEnabled + self.isRecordingEnabled = isRecordingEnabled + self.interval = interval + self.lastSuccessfulBackupAt = lastSuccessfulBackupAt + } +} + +public enum AutomaticBackupRunResult: Sendable, Equatable { + case disabled + case notDue(nextEligibleAt: Date) + case alreadyRunning + case deferredUntilFirstUnlock + case completed(exportedAt: Date) +} + +/// Single-flight coordinator for due checks, encrypted export, storage, and +/// catalog change notifications. +public actor AutomaticBackupService { + /// Background expiration owns cancellation; a disappearing view does not. + public enum CallerCancellation: Sendable { + case cancelExecution + /// Await the shared result even after cancellation, so the caller can + /// persist success. No further view work should run on that caller. + case finishExecution + } + + private let backup: BackupCoordinator + private let recoveryKeys: BackupRecoveryKeyProvider + private let storage: AutomaticBackupStorage + private let calendar: Calendar + private let now: @Sendable () -> Date + private let scheduler: any AutomaticBackupTaskScheduling + private struct Run { + let id: UUID + let task: Task + } + + private var run: Run? + private var configuration: AutomaticBackupConfiguration? + private var lastSuccessfulBackupAt: Date? + private var scheduleTask: Task? + private var scheduleRevision = 0 + private enum Availability { + case active + case suspended + case shutDown + } + + private var availability: Availability = .active + #if DEBUG + @_spi(Testing) public var isRetiredForTesting: Bool { + availability == .shutDown + } + #endif + private var changeContinuations: [UUID: AsyncStream.Continuation] = [:] + + public init( + backup: BackupCoordinator, + recoveryKeys: BackupRecoveryKeyProvider, + storage: AutomaticBackupStorage, + scheduler: any AutomaticBackupTaskScheduling = NoopAutomaticBackupTaskScheduler(), + calendar: Calendar = .current, + now: @escaping @Sendable () -> Date = { Date() }, + ) { + self.backup = backup + self.recoveryKeys = recoveryKeys + self.storage = storage + self.scheduler = scheduler + self.calendar = calendar + self.now = now + } + + public func runIfDue( + cancellation: CallerCancellation, + configuration: AutomaticBackupConfiguration, + ) async throws -> AutomaticBackupRunResult { + await reconcileSchedule(configuration: configuration) + try Task.checkCancellation() + guard availability == .active, + let configuration = self.configuration else { return .disabled } + guard configuration.isEnabled, configuration.isRecordingEnabled else { + return .disabled + } + let active: Run + if let run { + active = run + } else { + let id = UUID() + active = Run(id: id, task: Task { + try await BackupService.withCancellation { + try await self.performBackup() + } + }) + run = active + } + defer { if run?.id == active.id { run = nil } } + // All callers await the same result. Only an execution owner can + // cancel it; reset and recording disable retain their explicit authority. + return try await withTaskCancellationHandler { + try await active.task.value + } onCancel: { + if cancellation == .cancelExecution { active.task.cancel() } + } + } + + private func performBackup() async throws -> AutomaticBackupRunResult { + try Task.checkCancellation() + guard let configuration, availability == .active, + configuration.isEnabled, configuration.isRecordingEnabled else { return .disabled } + let currentDate = now() + if let lastSuccessfulBackupAt = effectiveLastSuccess { + let next = configuration.interval.nextDate( + after: lastSuccessfulBackupAt, + calendar: calendar, + ) + if currentDate < next { + await reconcileRetention() + try Task.checkCancellation() + return .notDue(nextEligibleAt: next) + } + } + let key: BackupRecoveryKey + do { + key = try await recoveryKeys.loadOrCreate() + } catch BackupRecoveryKeyProvider.ProviderError.deferredUntilFirstUnlock { + return .deferredUntilFirstUnlock + } + + try Task.checkCancellation() + _ = try await backup.writeAutomaticBackup( + recoveryKey: key, + exportedAt: currentDate, + storage: storage, + ) + try Task.checkCancellation() + lastSuccessfulBackupAt = currentDate + notifyChanges() + await updateSchedule() + // A committed export remains successful if maintenance fails. Never + // fall back or repeat an export just because pruning was unavailable. + await reconcileRetention() + guard availability == .active else { return .disabled } + return .completed(exportedAt: currentDate) + } + + public func reconcileSchedule(configuration: AutomaticBackupConfiguration) async { + guard availability != .shutDown else { return } + self.configuration = configuration + if !configuration.isEnabled || !configuration.isRecordingEnabled { run?.task.cancel() } + await updateSchedule() + } + + private var effectiveLastSuccess: Date? { + [lastSuccessfulBackupAt, configuration?.lastSuccessfulBackupAt].compactMap(\.self).max() + } + + private func updateSchedule() async { + scheduleRevision += 1 + if let scheduleTask { + await scheduleTask.value + return + } + let task = Task { await self.reconcileLatestSchedule() } + scheduleTask = task + await task.value + } + + private func reconcileLatestSchedule() async { + // Every reconciler joins this task. In particular, shutdown must not + // return while an old submission could still cancel a new scope's job. + defer { scheduleTask = nil } + while true { + let revision = scheduleRevision + let enabled = availability == .active && configuration?.isEnabled == true + && configuration?.isRecordingEnabled == true + let earliest = effectiveLastSuccess.map { + configuration?.interval.nextDate(after: $0, calendar: calendar) ?? now() + } ?? now() + await scheduler.reconcile( + isEnabled: enabled, + earliestBeginDate: enabled ? earliest : nil, + ) + if revision == scheduleRevision { return } + } + } + + private func reconcileRetention() async { + do { + try Task.checkCancellation() + try await storage.reconcileRetention(recoveryKeys: recoveryKeys) + notifyChanges() + } catch { + WhereLog.backup(AutomaticBackupLog.self) { + .cleanupFailed(description: error.localizedDescription) + } + } + } + + /// Drain this scope before erase/logout can replace its store or preferences. + public func shutDown() async { + availability = .shutDown + run?.task.cancel() + if let run { _ = await run.task.result } + await updateSchedule() + } + + public func cancelCurrentRun() async { + run?.task.cancel() + if let run { _ = await run.task.result } + await updateSchedule() + } + + public func suspend() async { + guard availability == .active else { return } + availability = .suspended + await cancelCurrentRun() + } + + public func resume() async { + guard availability == .suspended else { return } + availability = .active + await updateSchedule() + } + + public func catalog() async throws -> AutomaticBackupCatalog { + try await storage.catalog() + } + + public func recoveryKey() async throws -> String { + try await recoveryKeys.loadOrCreate().base64Encoded + } + + /// Resolves the synchronized key or validates a user-entered recovery key. + /// Entered keys are returned as values only and are never persisted. + public func restoreRecoveryKey( + archiveURL: URL, + explicitBase64: String?, + ) async throws -> BackupRecoveryKey? { + if let explicitBase64, !explicitBase64.isEmpty { + return try BackupRecoveryKey(base64Encoded: explicitBase64) + } + let task = Task.detached(priority: .utility) { + let scoped = archiveURL.startAccessingSecurityScopedResource() + defer { if scoped { archiveURL.stopAccessingSecurityScopedResource() } } + return try CoordinatedBackupFileAccess.read(at: archiveURL) { + try BackupService().readEncryptedEnvelope(at: $0).keyIdentifier + } + } + let identifier = try await withTaskCancellationHandler { + try await task.value + } onCancel: { task.cancel() } + try Task.checkCancellation() + return try await recoveryKeys.loadExisting(identifier: identifier) + } + + public func changes() -> AsyncStream { + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream() + changeContinuations[id] = continuation + continuation.onTermination = { [weak self] _ in + Task { await self?.removeChangeContinuation(id) } + } + return stream + } + + private func notifyChanges() { + for continuation in changeContinuations.values { + continuation.yield() + } + } + + private func removeChangeContinuation(_ id: UUID) { + changeContinuations[id] = nil + } +} diff --git a/Where/WhereCore/Sources/Backup/AutomaticBackupStorage.swift b/Where/WhereCore/Sources/Backup/AutomaticBackupStorage.swift new file mode 100644 index 000000000..5af0ee92d --- /dev/null +++ b/Where/WhereCore/Sources/Backup/AutomaticBackupStorage.swift @@ -0,0 +1,331 @@ +import CryptoKit +import Foundation +import ZIPFoundation + +/// Stores encrypted automatic backups in iCloud Drive when it can, falling +/// back to this installation's Documents directory. Only recognized encrypted +/// containers participate in the shared newest-three retention policy. +public actor AutomaticBackupStorage { + public enum StorageError: Error, LocalizedError { + case documentsDirectoryUnavailable + case downloadPending + case iCloudAndLocalWriteFailed(iCloud: String, local: String) + + public var errorDescription: String? { + switch self { + case .documentsDirectoryUnavailable: + "The app Documents directory is unavailable." + case .downloadPending: + "An iCloud backup has not finished downloading. Try again later." + case let .iCloudAndLocalWriteFailed(iCloud, local): + "The backup could not be saved to iCloud (\(iCloud)) or this device (\(local))." + } + } + } + + private struct Root { + let url: URL + let location: AutomaticBackupFile.StorageLocation + } + + private let backupService: BackupService + private let iCloudRoot: @Sendable () throws -> URL? + private let localRoot: @Sendable () throws -> URL + private let fileManager: FileManager + private let availability: any AutomaticBackupFileAvailabilityChecking + private let retainedFileCount: Int + private static let logger = WhereLog.backup(AutomaticBackupLog.self) + + public init( + iCloudContainerIdentifier: String = "iCloud.com.stuff.where", + retainedFileCount: Int = 3, + ) { + backupService = BackupService() + fileManager = .default + availability = SystemAutomaticBackupFileAvailability() + self.retainedFileCount = retainedFileCount + iCloudRoot = { + FileManager.default.url(forUbiquityContainerIdentifier: iCloudContainerIdentifier)? + .appendingPathComponent("Documents/Where Backups", isDirectory: true) + } + localRoot = { + guard let documents = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask, + ).first else { + throw StorageError.documentsDirectoryUnavailable + } + return documents.appendingPathComponent("Where Backups", isDirectory: true) + } + } + + @_spi(Testing) + public init( + iCloudRoot: @escaping @Sendable () throws -> URL?, + localRoot: @escaping @Sendable () throws -> URL, + retainedFileCount: Int = 3, + availability: any AutomaticBackupFileAvailabilityChecking = + SystemAutomaticBackupFileAvailability(), + ) { + backupService = BackupService() + fileManager = .default + self.availability = availability + self.iCloudRoot = iCloudRoot + self.localRoot = localRoot + self.retainedFileCount = retainedFileCount + } + + @discardableResult + public func store(_ stagedArchive: URL) throws -> AutomaticBackupFile { + try Task.checkCancellation() + var iCloudFailure: Error? + do { + if let cloudRoot = try iCloudRoot() { + return try write( + stagedArchive, + to: Root(url: cloudRoot, location: .iCloudDrive), + coordinated: true, + ) + } else { + Self.logger { .iCloudUnavailable } + } + } catch is CancellationError { + throw CancellationError() + } catch { + Self.logger { .iCloudAccessFailed(description: error.localizedDescription) } + iCloudFailure = error + } + + do { + return try write( + stagedArchive, + to: Root(url: localRoot(), location: .appDocuments), + coordinated: false, + ) + } catch is CancellationError { + throw CancellationError() + } catch { + if let iCloudFailure { + throw StorageError.iCloudAndLocalWriteFailed( + iCloud: iCloudFailure.localizedDescription, + local: error.localizedDescription, + ) + } + throw error + } + } + + /// Catalog work has its own cancellable I/O context, outside the storage + /// actor. A blocked reader must not hold up exports or their cancellation. + @concurrent + public nonisolated func catalog() async throws -> AutomaticBackupCatalog { + try await BackupService.withCancellation { try readCatalog() } + } + + private nonisolated func readCatalog() throws -> AutomaticBackupCatalog { + try Task.checkCancellation() + var files: [AutomaticBackupFile] = [] + var isICloudUnavailable = false + + do { + if let cloudRoot = try iCloudRoot() { + let cloud = try enumerate( + Root(url: cloudRoot, location: .iCloudDrive), + coordinated: true, + ) + files += cloud.files + isICloudUnavailable = cloud.isICloudUnavailable + } else { + isICloudUnavailable = true + Self.logger { .iCloudUnavailable } + } + } catch is CancellationError { + throw CancellationError() + } catch { + try Task.checkCancellation() + isICloudUnavailable = true + Self.logger { .iCloudAccessFailed(description: error.localizedDescription) } + } + + files += try enumerate( + Root(url: localRoot(), location: .appDocuments), + coordinated: false, + ).files + try Task.checkCancellation() + return AutomaticBackupCatalog( + files: files.sorted { + if $0.exportedAt == $1.exportedAt { return $0.url.path < $1.url.path } + return $0.exportedAt > $1.exportedAt + }, + isICloudUnavailable: isICloudUnavailable, + ) + } + + private func write( + _ source: URL, + to root: Root, + coordinated: Bool, + ) throws -> AutomaticBackupFile { + try Task.checkCancellation() + let metadata = try describe(source, location: root.location) + try fileManager.createDirectory(at: root.url, withIntermediateDirectories: true) + let destination = root.url.appendingPathComponent(source.lastPathComponent) + let operation = { (coordinatedURL: URL) in + let temporary = coordinatedURL.deletingLastPathComponent() + .appendingPathComponent(".\(UUID().uuidString).tmp") + defer { self.removeStagingItemIfPresent(at: temporary) } + try self.fileManager.copyItem(at: source, to: temporary) + try Task.checkCancellation() + try self.fileManager.moveItem(at: temporary, to: coordinatedURL) + return coordinatedURL + } + let storedURL: URL = if coordinated { + try CoordinatedBackupFileAccess.write( + at: destination, + options: [], + operation: operation, + ) + } else { + try operation(destination) + } + // No fallible work after the atomic commit: the caller must learn that + // this file exists even if later catalog or retention work fails. + return AutomaticBackupFile( + url: storedURL, + exportedAt: metadata.exportedAt, + byteCount: metadata.byteCount, + storageLocation: root.location, + protection: metadata.protection, + ) + } + + private nonisolated func enumerate( + _ root: Root, + coordinated: Bool, + ) throws -> AutomaticBackupCatalog { + let urls: [URL] + do { + let fileManager = FileManager() + let operation = { (url: URL) in + try fileManager.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey], + options: [.skipsHiddenFiles], + ) + } + urls = try coordinated + ? CoordinatedBackupFileAccess.read( + at: root.url, + options: .immediatelyAvailableMetadataOnly, + operation: operation, + ) + : operation(root.url) + } catch CocoaError.fileReadNoSuchFile { + return AutomaticBackupCatalog(files: [], isICloudUnavailable: false) + } + var files: [AutomaticBackupFile] = [] + var hasUnavailableFiles = false + for url in urls where url.pathExtension.lowercased() == "wherebackup" { + try Task.checkCancellation() + // A matching extension alone does not make this one of our + // automatic files. Ignore malformed or foreign containers + // so catalog and retention never delete unrecognized data. + do { + // This must precede content coordination: that call otherwise + // waits for iCloud to download the item before its accessor runs. + if coordinated, try !availability.isDownloaded(at: url) { + hasUnavailableFiles = true + Self + .logger { + .iCloudAccessFailed(description: StorageError.downloadPending + .localizedDescription) + } + continue + } + let operation = { (coordinatedURL: URL) in + try self.describe(coordinatedURL, location: root.location) + } + let file = try coordinated + ? CoordinatedBackupFileAccess.read(at: url, operation: operation) + : operation(url) + files.append(file) + } catch is BackupService.EncryptedBackupError { + Self.logger { .ignoredUnrecognizedFile(name: url.lastPathComponent) } + } catch is DecodingError { + Self.logger { .ignoredUnrecognizedFile(name: url.lastPathComponent) } + } catch is Archive.ArchiveError { + Self.logger { .ignoredUnrecognizedFile(name: url.lastPathComponent) } + } catch { + try Task.checkCancellation() + guard coordinated else { throw error } + hasUnavailableFiles = true + Self.logger { .iCloudAccessFailed(description: error.localizedDescription) } + } + } + return AutomaticBackupCatalog(files: files, isICloudUnavailable: hasUnavailableFiles) + } + + private nonisolated func describe( + _ url: URL, + location: AutomaticBackupFile.StorageLocation, + ) throws -> AutomaticBackupFile { + if try !availability.isDownloaded(at: url) { + throw StorageError.downloadPending + } + // Archive's failable initializer cannot distinguish I/O from format + // errors. Check readability first so access failures remain observable. + let handle = try FileHandle(forReadingFrom: url) + try handle.close() + let envelope = try backupService.readEncryptedEnvelope(at: url) + let values = try? url.resourceValues(forKeys: [.fileSizeKey]) + return AutomaticBackupFile( + url: url, + exportedAt: envelope.exportedAt, + byteCount: values?.fileSize.map(Int64.init), + storageLocation: location, + protection: .aesGCM256, + ) + } + + /// Only authenticated, readable archives with available recovery keys may + /// displace another recoverable archive. Unknown keys and invalid files stay. + public func reconcileRetention(recoveryKeys: BackupRecoveryKeyProvider) async throws { + let catalog = try await catalog() + var verified: [AutomaticBackupRetention.VerifiedFile] = [] + for file in catalog.files { + try Task.checkCancellation() + let identifier = try CoordinatedBackupFileAccess.read(at: file.url) { + try self.backupService.readEncryptedEnvelope(at: $0).keyIdentifier + } + guard let key = try await recoveryKeys.loadExisting(identifier: identifier) else { + continue + } + do { + let candidate = try CoordinatedBackupFileAccess.read(at: file.url) { url in + _ = try self.backupService.readEncryptedArchive(at: url, recoveryKey: key) + return try AutomaticBackupRetention.VerifiedFile( + file: file, + digest: SHA256.hash(data: Data(contentsOf: url, options: .mappedIfSafe)), + exportedAt: self.backupService.readEncryptedEnvelope(at: url).exportedAt, + ) + } + verified.append(candidate) + } catch is CancellationError { + throw CancellationError() + } catch { + Self.logger { .ignoredUnrecognizedFile(name: file.url.lastPathComponent) } + } + } + try await AutomaticBackupRetention(verified: verified, retainedFileCount: retainedFileCount) + .prune() + } + + private func removeStagingItemIfPresent(at url: URL) { + guard fileManager.fileExists(atPath: url.path) else { return } + do { + try fileManager.removeItem(at: url) + } catch { + Self.logger { .cleanupFailed(description: error.localizedDescription) } + } + } +} diff --git a/Where/WhereCore/Sources/Backup/AutomaticBackupTaskScheduling.swift b/Where/WhereCore/Sources/Backup/AutomaticBackupTaskScheduling.swift new file mode 100644 index 000000000..f859bf4a2 --- /dev/null +++ b/Where/WhereCore/Sources/Backup/AutomaticBackupTaskScheduling.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Platform scheduling boundary for automatic backups. The app target backs +/// this with `BGTaskScheduler`; Core and tests can inject a no-op or spy. +public protocol AutomaticBackupTaskScheduling: Sendable { + func reconcile(isEnabled: Bool, earliestBeginDate: Date?) async +} + +public struct NoopAutomaticBackupTaskScheduler: AutomaticBackupTaskScheduling { + public init() {} + + public func reconcile(isEnabled _: Bool, earliestBeginDate _: Date?) async {} +} diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift index 976033687..9d8b54b64 100644 --- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift @@ -10,6 +10,14 @@ import RegionKit /// UI directly through `WhereServices.backup`; construction stays in-module via /// the internal `init`. public actor BackupCoordinator { + public struct RecoveryKeyRequiredError: Error, LocalizedError { + public init() {} + + public var errorDescription: String? { + "A recovery key is required to open this encrypted backup." + } + } + /// How an imported backup combines with whatever is already on the device. public enum ImportStrategy: Sendable, Hashable { /// Upsert the imported rows into the existing data (by `id` for @@ -102,6 +110,18 @@ public actor BackupCoordinator { /// archive on disk). Actor-isolated, so it survives the UI that triggered /// the export being torn down. private var previousExportDirectory: URL? + private var exportIsActive = false + private struct ExportWaiter { + let id: UUID + let continuation: CheckedContinuation + } + + private var exportWaiters: [ExportWaiter] = [] + + private enum ExportStagingOwnership: Equatable { + case manualShare + case automatic + } init( store: any WhereStore, @@ -139,17 +159,59 @@ public actor BackupCoordinator { public func exportBackup( onProgress: @Sendable (Double) -> Void = { _ in }, ) async throws -> URL { - try await Self.logger.measure(.exportBackup) { - try await performExport(onProgress: onProgress) + try await acquireExportPermit() + defer { releaseExportPermit() } + try Task.checkCancellation() + return try await Self.logger.measure(.exportBackup) { + try await performExport( + onProgress: onProgress, + exportedAt: nil, + stagingOwnership: .manualShare, + ) } } + /// Runs the complete automatic operation under the same export permit as + /// manual export: snapshot, ZIP, encryption, coordinated movement, and + /// retention cannot overlap another export's staging lifecycle. + public func writeAutomaticBackup( + recoveryKey: BackupRecoveryKey, + exportedAt: Date, + storage: AutomaticBackupStorage, + ) async throws -> AutomaticBackupFile { + try await acquireExportPermit() + defer { releaseExportPermit() } + try Task.checkCancellation() + + let plaintext = try await performExport( + onProgress: { _ in }, + exportedAt: exportedAt, + stagingOwnership: .automatic, + ) + defer { Self.removeAutomaticStagingDirectory(plaintext.deletingLastPathComponent()) } + + let backupService = backupService + let encrypted = try await Self.runDetached(priority: .utility) { + try backupService.makeEncryptedArchiveFile( + from: plaintext, + recoveryKey: recoveryKey, + exportedAt: exportedAt, + ) + } + defer { Self.removeAutomaticStagingDirectory(encrypted.deletingLastPathComponent()) } + return try await storage.store(encrypted) + } + /// `exportBackup`'s body, split out so the outer span reads as one leg-by-leg /// tree rather than wrapping a `return`. private func performExport( onProgress: @Sendable (Double) -> Void, + exportedAt: Date?, + stagingOwnership: ExportStagingOwnership, ) async throws -> URL { - purgePreviousExport() + if stagingOwnership == .manualShare { + purgePreviousExport() + } let snapshot = try await store.readSnapshot { let tables = try await Self.logger.measure(.exportReads) { @@ -174,6 +236,7 @@ public actor BackupCoordinator { try await Self.logger.measure(.exportBlobLoad) { var lastPercent = -1 for (index, item) in evidence.enumerated() { + try Task.checkCancellation() if let blob = try await store.evidenceBlob(for: item.id) { blobs[item.id] = blob } @@ -189,7 +252,7 @@ public actor BackupCoordinator { } let tables = snapshot.tables let backupService = backupService - let url = try await Task.detached(priority: .utility) { + let url = try await Self.runDetached(priority: .utility) { try backupService.makeArchiveFile( samples: tables.samples, evidence: tables.evidence, @@ -203,13 +266,74 @@ public actor BackupCoordinator { recordingDeviceRemovals: tables.recordingDeviceRemovals, plannedStayRecords: tables.plannedStayRecords, blobs: snapshot.blobs, + exportedAt: exportedAt ?? Date(), ) - }.value + } + if Task.isCancelled { + Self.removeAutomaticStagingDirectory(url.deletingLastPathComponent()) + throw CancellationError() + } onProgress(1) - previousExportDirectory = url.deletingLastPathComponent() + if stagingOwnership == .manualShare { + previousExportDirectory = url.deletingLastPathComponent() + } return url } + /// Run synchronous file/crypto work off the caller's executor while still + /// forwarding structured cancellation to the child task. Each operation + /// checks cancellation after opaque system calls return, which makes their + /// staging cleanup run before the cancelled parent completes. + private static func runDetached( + priority: TaskPriority, + operation: @escaping @Sendable () throws -> Value, + ) async throws -> Value { + let progress = Progress(totalUnitCount: 0) + let task = Task.detached(priority: priority) { + try Task.checkCancellation() + // The operation owns cleanup until it returns. Do not throw after + // transferring a staging URL: the caller must receive it to clean up. + return try BackupService.$cancellationProgress.withValue(progress) { + try operation() + } + } + return try await withTaskCancellationHandler { + try await task.value + } onCancel: { + progress.cancel() + task.cancel() + } + } + + private func acquireExportPermit() async throws { + try Task.checkCancellation() + guard exportIsActive else { + exportIsActive = true + return + } + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + exportWaiters.append(ExportWaiter(id: id, continuation: continuation)) + } + } onCancel: { + Task { await self.cancelExportWaiter(id) } + } + } + + private func releaseExportPermit() { + guard !exportWaiters.isEmpty else { + exportIsActive = false + return + } + exportWaiters.removeFirst().continuation.resume() + } + + private func cancelExportWaiter(_ id: UUID) { + guard let index = exportWaiters.firstIndex(where: { $0.id == id }) else { return } + exportWaiters.remove(at: index).continuation.resume(throwing: CancellationError()) + } + /// Everything an export reads out of the store before it starts on blobs. /// A named value rather than five locals so the whole read leg fits inside /// one span without threading a tuple through it. @@ -253,6 +377,16 @@ public actor BackupCoordinator { } } + private static func removeAutomaticStagingDirectory(_ url: URL) { + do { + try FileManager.default.removeItem(at: url) + } catch { + WhereLog.backup(AutomaticBackupLog.self) { + .cleanupFailed(description: error.localizedDescription) + } + } + } + /// Read a backup `.zip` and write its contents back into the store inside a /// single transaction. `.replace` wipes user history/settings first while retaining the /// append-only device ledger; `.merge` relies on the store's upsert semantics. Tracked @@ -266,6 +400,7 @@ public actor BackupCoordinator { public func importBackup( from url: URL, strategy: ImportStrategy, + recoveryKey: BackupRecoveryKey? = nil, onProgress: @Sendable (Double) -> Void, ) async throws -> ImportSummary { try await hydrateImportRecovery() @@ -290,6 +425,7 @@ public actor BackupCoordinator { from: url, strategy: strategy, transactionID: operationID, + recoveryKey: recoveryKey, onProgress: onProgress, ) } @@ -380,6 +516,7 @@ public actor BackupCoordinator { from url: URL, strategy: ImportStrategy, transactionID: UUID, + recoveryKey: BackupRecoveryKey?, onProgress: @Sendable (Double) -> Void, ) async throws -> ImportSummary { let expectedGenerationID = try await (store.dataGeneration()).id @@ -390,9 +527,18 @@ public actor BackupCoordinator { defer { if accessing { url.stopAccessingSecurityScopedResource() } } let backupService = backupService - let result = try await Task.detached(priority: .utility) { - try backupService.readArchive(at: url) - }.value + let result = try await Self.runDetached(priority: .utility) { + try CoordinatedBackupFileAccess.read(at: url) { coordinatedURL in + if url.pathExtension.lowercased() == "wherebackup" { + guard let recoveryKey else { throw RecoveryKeyRequiredError() } + return try backupService.readEncryptedArchive( + at: coordinatedURL, + recoveryKey: recoveryKey, + ) + } + return try backupService.readArchive(at: coordinatedURL) + } + } let archive = result.archive let blobs = result.blobs let summary = ImportSummary( diff --git a/Where/WhereCore/Sources/Backup/BackupRecoveryKeyProvider.swift b/Where/WhereCore/Sources/Backup/BackupRecoveryKeyProvider.swift new file mode 100644 index 000000000..cc7b07069 --- /dev/null +++ b/Where/WhereCore/Sources/Backup/BackupRecoveryKeyProvider.swift @@ -0,0 +1,204 @@ +import CryptoKit +import Foundation +@_spi(Testing) import KeychainKit +import Security + +/// The 256-bit secret used to encrypt automatic backups. Its Base64 form is +/// the recovery value shown to the user; callers must never log either form. +public struct BackupRecoveryKey: Hashable, Sendable { + public static let byteCount = 32 + + let data: Data + + public init(base64Encoded value: String) throws { + guard let data = Data(base64Encoded: value), data.count == Self.byteCount else { + throw BackupRecoveryKeyProvider.ProviderError.malformedKey + } + self.data = data + } + + init(data: Data) throws { + guard data.count == Self.byteCount else { + throw BackupRecoveryKeyProvider.ProviderError.malformedKey + } + self.data = data + } + + public var base64Encoded: String { + data.base64EncodedString() + } + + public var identifier: String { + Data(SHA256.hash(data: data).prefix(12)) + .base64EncodedString() + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "=", with: "") + } + + var symmetricKey: SymmetricKey { + SymmetricKey(data: data) + } +} + +/// Pins this installation's active secret locally and preserves each secret +/// under its own synchronized account. No synchronized secret is overwritten. +public actor BackupRecoveryKeyProvider { + public enum ProviderError: Error, LocalizedError, Equatable { + case deferredUntilFirstUnlock + case malformedKey + case keychain(KeychainError) + + public var errorDescription: String? { + switch self { + case .deferredUntilFirstUnlock: + "The backup key is unavailable until this device is unlocked." + case .malformedKey: + "The backup recovery key is not valid." + case let .keychain(error): + error.localizedDescription + } + } + } + + public static let service = "com.stuff.where" + public static let account = "automatic-backup-recovery-key-v1" + + private let store: any KeychainStore + private let legacyStore: any KeychainStore + private let collection: any KeychainCollection + private let isProtectedDataAvailable: @Sendable () async -> Bool + + public init( + store: any KeychainStore, + legacyStore: any KeychainStore, + collection: any KeychainCollection, + isProtectedDataAvailable: @escaping @Sendable () async -> Bool, + ) { + self.store = store + self.legacyStore = legacyStore + self.collection = collection + self.isProtectedDataAvailable = isProtectedDataAvailable + } + + @_spi(Testing) + public init( + store: any KeychainStore, + isProtectedDataAvailable: @escaping @Sendable () async -> Bool, + ) { + self.store = store + legacyStore = InMemoryKeychainStore() + collection = InMemoryKeychainCollection() + self.isProtectedDataAvailable = isProtectedDataAvailable + } + + public static func system( + isProtectedDataAvailable: @escaping @Sendable () async -> Bool, + ) -> BackupRecoveryKeyProvider { + BackupRecoveryKeyProvider( + store: SystemKeychainStore( + service: service, + account: "automatic-backup-active-key-v2", + accessibility: .afterFirstUnlock, + synchronizesThroughICloud: false, + ), + legacyStore: SystemKeychainStore( + service: service, + account: account, + accessibility: .afterFirstUnlock, + synchronizesThroughICloud: true, + ), + collection: SystemKeychainCollection( + service: "com.stuff.where.automatic-backup-keys-v2", + accessibility: .afterFirstUnlock, + synchronizesThroughICloud: true, + ), + isProtectedDataAvailable: isProtectedDataAvailable, + ) + } + + public func loadOrCreate() async throws -> BackupRecoveryKey { + guard await isProtectedDataAvailable() else { + throw ProviderError.deferredUntilFirstUnlock + } + try Task.checkCancellation() + + do { + let legacy = try legacyStore.read() + if let legacy { try preserve(BackupRecoveryKey(data: legacy)) } + if let existing = try store.read() { + let key = try BackupRecoveryKey(data: existing) + try preserve(key) + return key + } + + let created = try BackupRecoveryKey( + data: legacy ?? Data((0 ..< BackupRecoveryKey.byteCount).map { _ in + UInt8.random(in: .min ... .max) + }), + ) + // Preserve before pinning or exporting. Two offline installations + // create different accounts, so later synchronization retains both. + try preserve(created) + do { + try store.create(created.data) + return created + } catch let error as KeychainError where error.status == errSecDuplicateItem { + // Only the local pin can race. Both candidates remain in the + // synchronized collection, including the losing candidate. + guard let winner = try store.read() else { + throw ProviderError.keychain(error) + } + let key = try BackupRecoveryKey(data: winner) + try preserve(key) + return key + } + } catch let error as ProviderError { + throw error + } catch let error as KeychainError where error.isInteractionNotAllowed { + throw ProviderError.deferredUntilFirstUnlock + } catch let error as KeychainError { + throw ProviderError.keychain(error) + } + } + + private func preserve(_ key: BackupRecoveryKey) throws { + let account = KeychainAccount(key.identifier) + do { + try collection.create(key.data, account: account) + } catch let error as KeychainError where error.status == errSecDuplicateItem { + guard try collection.read(account: account) == key.data else { + throw ProviderError.malformedKey + } + } + } + + /// Resolve the envelope's exact key, including keys created on another + /// installation. A user-entered key never goes through this write path. + public func loadExisting(identifier: String) async throws -> BackupRecoveryKey? { + guard await isProtectedDataAvailable() else { + throw ProviderError.deferredUntilFirstUnlock + } + do { + if let data = try collection.read(account: KeychainAccount(identifier)) { + let key = try BackupRecoveryKey(data: data) + guard key.identifier == identifier else { throw ProviderError.malformedKey } + return key + } + for candidate in try [store.read(), legacyStore.read()] { + if let candidate { + let key = try BackupRecoveryKey(data: candidate) + if key.identifier == identifier { + try preserve(key) + return key + } + } + } + return nil + } catch let error as KeychainError where error.isInteractionNotAllowed { + throw ProviderError.deferredUntilFirstUnlock + } catch let error as KeychainError { + throw ProviderError.keychain(error) + } + } +} diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift index 686a8f27e..5e1adf7d4 100644 --- a/Where/WhereCore/Sources/Backup/BackupService.swift +++ b/Where/WhereCore/Sources/Backup/BackupService.swift @@ -15,6 +15,17 @@ import ZIPFoundation /// SwiftData. `BackupCoordinator` owns reading the store and committing an /// import transaction; this type only marshals bytes to and from the zip. public struct BackupService: Sendable { + @TaskLocal static var cancellationProgress: Progress? + + static func withCancellation( + _ operation: @Sendable () async throws -> Value, + ) async throws -> Value { + let progress = Progress(totalUnitCount: 0) + return try await withTaskCancellationHandler { + try await $cancellationProgress.withValue(progress) { try await operation() } + } onCancel: { progress.cancel() } + } + /// Header decoded before the strict current archive shape, so an older manifest reports its /// format version instead of failing first on a field introduced by a later format. private struct FormatEnvelope: Decodable { @@ -112,62 +123,80 @@ public struct BackupService: Sendable { .appendingPathComponent("where-backup-\(UUID().uuidString)", isDirectory: true) let staging = workRoot.appendingPathComponent("contents", isDirectory: true) let assetsDir = staging.appendingPathComponent(Self.assetsDirectory, isDirectory: true) - try fileManager.createDirectory(at: assetsDir, withIntermediateDirectories: true) - - var assetEntries: [BackupAssetEntry] = [] - try Self.logger.measure(.stageAssets) { - for item in evidence { - guard let blob = blobs[item.id] else { continue } - // Drain each write's file-I/O scratch (URL/Data bridging) per - // iteration so a large evidence set doesn't pile up autoreleased - // temporaries until the whole export finishes. - try autoreleasepool { - let filename = "\(Self.assetsDirectory)/\(item.id.uuidString)" - try blob.write(to: staging.appendingPathComponent(filename)) - assetEntries.append(BackupAssetEntry(evidenceId: item.id, filename: filename)) + do { + try fileManager.createDirectory(at: assetsDir, withIntermediateDirectories: true) + var assetEntries: [BackupAssetEntry] = [] + try Self.logger.measure(.stageAssets) { + for item in evidence { + try Task.checkCancellation() + guard let blob = blobs[item.id] else { continue } + // Drain each write's file-I/O scratch (URL/Data bridging) per + // iteration so a large evidence set doesn't pile up autoreleased + // temporaries until the whole export finishes. + try autoreleasepool { + let filename = "\(Self.assetsDirectory)/\(item.id.uuidString)" + try blob.write(to: staging.appendingPathComponent(filename)) + assetEntries.append(BackupAssetEntry( + evidenceId: item.id, + filename: filename, + )) + } } } - } - - let archive = BackupArchive( - exportedAt: exportedAt, - samples: samples, - evidence: evidence, - manualDays: manualDays, - dismissedIssues: dismissedIssues, - trackedRegions: trackedRegions, - primaryRegions: primaryRegions, - recordingDeviceProfiles: recordingDeviceProfiles, - recordingDeviceMetadataChanges: recordingDeviceMetadataChanges, - recordingDeviceRemovals: recordingDeviceRemovals, - plannedStayRecords: plannedStayRecords, - assets: assetEntries, - ) - try Self.logger.measure(.encodeManifest) { - let manifestData = try Self.makeEncoder().encode(archive) - try manifestData.write(to: staging.appendingPathComponent(Self.manifestFilename)) - } - let name = archiveName ?? Self.defaultArchiveName(for: exportedAt) - let zipURL = workRoot.appendingPathComponent(name) - try Self.logger.measure(.writeArchive) { - try fileManager.zipItem( - at: staging, - to: zipURL, - shouldKeepParent: false, - compressionMethod: .deflate, - ) - } - Self.logger { - .wroteBackup( - sampleCount: samples.count, - evidenceCount: evidence.count, - manualDayCount: manualDays.count, - dismissedIssueCount: dismissedIssues.count, - trackedRegionCount: trackedRegions.count, + try Task.checkCancellation() + let archive = BackupArchive( + exportedAt: exportedAt, + samples: samples, + evidence: evidence, + manualDays: manualDays, + dismissedIssues: dismissedIssues, + trackedRegions: trackedRegions, + primaryRegions: primaryRegions, + recordingDeviceProfiles: recordingDeviceProfiles, + recordingDeviceMetadataChanges: recordingDeviceMetadataChanges, + recordingDeviceRemovals: recordingDeviceRemovals, + plannedStayRecords: plannedStayRecords, + assets: assetEntries, ) + try Self.logger.measure(.encodeManifest) { + let manifestData = try Self.makeEncoder().encode(archive) + try manifestData.write(to: staging.appendingPathComponent(Self.manifestFilename)) + } + + try Task.checkCancellation() + let name = archiveName ?? Self.defaultArchiveName(for: exportedAt) + let zipURL = workRoot.appendingPathComponent(name) + try Self.logger.measure(.writeArchive) { + try fileManager.zipItem( + at: staging, + to: zipURL, + shouldKeepParent: false, + compressionMethod: .deflate, + progress: Self.cancellationProgress, + ) + } + try Task.checkCancellation() + Self.logger { + .wroteBackup( + sampleCount: samples.count, + evidenceCount: evidence.count, + manualDayCount: manualDays.count, + dismissedIssueCount: dismissedIssues.count, + trackedRegionCount: trackedRegions.count, + ) + } + return zipURL + } catch { + do { + try fileManager.removeItem(at: workRoot) + } catch let cleanupError { + Self.logger { + .stagingCleanupFailed(description: cleanupError.localizedDescription) + } + } + throw error } - return zipURL } /// A human-friendly, email-ready filename like @@ -194,7 +223,7 @@ public struct BackupService: Sendable { defer { try? fileManager.removeItem(at: extractDir) } try Self.logger.measure(.readArchive) { - try fileManager.unzipItem(at: url, to: extractDir) + try fileManager.unzipItem(at: url, to: extractDir, progress: Self.cancellationProgress) } let manifestURL = extractDir.appendingPathComponent(Self.manifestFilename) @@ -220,6 +249,7 @@ public struct BackupService: Sendable { var blobs: [UUID: Data] = [:] try Self.logger.measure(.loadAssets) { for entry in entries { + try Task.checkCancellation() // Drain the per-read bridging scratch each iteration so walking a // large asset set doesn't accumulate transient temporaries (the // decoded blobs themselves are retained in `blobs`). diff --git a/Where/WhereCore/Sources/Backup/CoordinatedBackupFileAccess.swift b/Where/WhereCore/Sources/Backup/CoordinatedBackupFileAccess.swift new file mode 100644 index 000000000..1e32e4e30 --- /dev/null +++ b/Where/WhereCore/Sources/Backup/CoordinatedBackupFileAccess.swift @@ -0,0 +1,123 @@ +import Foundation +import os + +/// Coordinates individual archive accesses, always using the URL supplied by +/// the coordinator. Directory coordination alone does not protect child data. +enum CoordinatedBackupFileAccess { + /// Only cancel() crosses threads; Apple explicitly permits that operation. + /// All other coordinator access stays on the synchronous caller's thread. + /// https://developer.apple.com/documentation/foundation/nsfilecoordinator/cancel() + private struct CancellationHandle: @unchecked Sendable { + private let coordinator: NSFileCoordinator + + init(_ coordinator: NSFileCoordinator) { + self.coordinator = coordinator + } + + func cancel() { + coordinator.cancel() + } + } + + static func read( + at url: URL, + operation: (URL) throws -> Value, + ) throws -> Value { + try read(at: url, options: [], operation: operation) + } + + static func read( + at url: URL, + options: NSFileCoordinator.ReadingOptions, + operation: (URL) throws -> Value, + ) throws -> Value { + let result = OSAllocatedUnfairLock?>(uncheckedState: nil) + var error: NSError? + let coordinator = NSFileCoordinator(filePresenter: nil) + let cancellation = CancellationHandle(coordinator) + let progress = BackupService.cancellationProgress + progress?.cancellationHandler = { cancellation.cancel() } + defer { progress?.cancellationHandler = nil } + try Task.checkCancellation() + coordinator.coordinate( + readingItemAt: url, + options: options, + error: &error, + ) { coordinatedURL in + let value = Result { try operation(coordinatedURL) } + result.withLock { $0 = value } + } + if let error { throw error } + guard let value = result.withLock({ $0 }) else { + try Task.checkCancellation() + preconditionFailure("File coordination did not execute its accessor.") + } + return try value.get() + } + + /// Acquire all keeper reads and the candidate deletion together. Nested + /// coordinators can deadlock and directory claims do not protect children. + static func delete( + at url: URL, + keeping keeperURLs: [URL], + operation: @escaping @Sendable (URL, [URL], Progress) throws -> Void, + ) async throws { + let candidate = NSFileAccessIntent.writingIntent(with: url, options: .forDeleting) + let keepers = keeperURLs.map { NSFileAccessIntent.readingIntent(with: $0, options: []) } + let coordinator = NSFileCoordinator(filePresenter: nil) + let cancellation = CancellationHandle(coordinator) + let progress = Progress(totalUnitCount: 1) + // The accessor runs outside the Swift task; pass its cancellation flag + // explicitly and retain the coordinator until the callback has drained. + defer { withExtendedLifetime(coordinator) {} } + try await withTaskCancellationHandler { + try Task.checkCancellation() + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + Void, + Error + >) in + coordinator + .coordinate(with: keepers + [candidate], queue: OperationQueue()) { error in + let result = Result { + if progress.isCancelled { throw CancellationError() } + if let error { throw error } + try operation(candidate.url, keepers.map(\.url), progress) + } + continuation.resume(with: result) + } + } + } onCancel: { + progress.cancel() + cancellation.cancel() + } + } + + static func write( + at url: URL, + options: NSFileCoordinator.WritingOptions, + operation: (URL) throws -> Value, + ) throws -> Value { + let result = OSAllocatedUnfairLock?>(uncheckedState: nil) + var error: NSError? + let coordinator = NSFileCoordinator(filePresenter: nil) + let cancellation = CancellationHandle(coordinator) + let progress = BackupService.cancellationProgress + progress?.cancellationHandler = { cancellation.cancel() } + defer { progress?.cancellationHandler = nil } + try Task.checkCancellation() + coordinator.coordinate( + writingItemAt: url, + options: options, + error: &error, + ) { coordinatedURL in + let value = Result { try operation(coordinatedURL) } + result.withLock { $0 = value } + } + if let error { throw error } + guard let value = result.withLock({ $0 }) else { + try Task.checkCancellation() + preconditionFailure("File coordination did not execute its accessor.") + } + return try value.get() + } +} diff --git a/Where/WhereCore/Sources/Backup/EncryptedBackupEnvelope.swift b/Where/WhereCore/Sources/Backup/EncryptedBackupEnvelope.swift new file mode 100644 index 000000000..d235a8437 --- /dev/null +++ b/Where/WhereCore/Sources/Backup/EncryptedBackupEnvelope.swift @@ -0,0 +1,208 @@ +import CryptoKit +import Foundation +import ZIPFoundation + +/// Public metadata at the root of an encrypted `.wherebackup` container. +/// The canonical fields are authenticated as AES-GCM additional data. +public struct EncryptedBackupEnvelope: Codable, Hashable, Sendable { + public static let currentVersion = 1 + public static let aesGCM256 = "aes-gcm-256" + + public let version: Int + public let protection: String + public let keyIdentifier: String + public let exportedAt: Date + + public init( + version: Int = EncryptedBackupEnvelope.currentVersion, + protection: String = EncryptedBackupEnvelope.aesGCM256, + keyIdentifier: String, + exportedAt: Date, + ) { + self.version = version + self.protection = protection + self.keyIdentifier = keyIdentifier + self.exportedAt = exportedAt + } + + var authenticatedData: Data { + Data("\(version)\n\(protection)\n\(keyIdentifier)\n\(exportedAt.timeIntervalSince1970)" + .utf8) + } +} + +extension BackupService { + public enum EncryptedBackupError: Error, LocalizedError, Equatable { + case envelopeMissing + case encryptedArchiveMissing + case unsupportedEnvelopeVersion(Int) + case unsupportedProtection(String) + case recoveryKeyMismatch + case authenticationFailed + + public var errorDescription: String? { + switch self { + case .envelopeMissing: "This encrypted backup has no envelope." + case .encryptedArchiveMissing: "This encrypted backup has no archive payload." + case let .unsupportedEnvelopeVersion(version): + "This backup uses unsupported envelope version \(version)." + case let .unsupportedProtection(protection): + "This backup uses unsupported protection \(protection)." + case .recoveryKeyMismatch: "This recovery key does not match the backup." + case .authenticationFailed: "The backup could not be authenticated or decrypted." + } + } + } + + /// Encrypts an existing plaintext Where ZIP into a versioned outer ZIP. + public func makeEncryptedArchiveFile( + from archiveURL: URL, + recoveryKey: BackupRecoveryKey, + exportedAt: Date, + ) throws -> URL { + try Task.checkCancellation() + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent( + "where-encrypted-backup-\(UUID().uuidString)", + isDirectory: true, + ) + let staging = root.appendingPathComponent("contents", isDirectory: true) + do { + try fileManager.createDirectory(at: staging, withIntermediateDirectories: true) + let envelope = EncryptedBackupEnvelope( + keyIdentifier: recoveryKey.identifier, + exportedAt: exportedAt, + ) + let envelopeData = try Self.makeEncoder().encode(envelope) + try envelopeData.write( + to: staging.appendingPathComponent("envelope.json"), + options: .atomic, + ) + + try Task.checkCancellation() + let plaintext = try Data(contentsOf: archiveURL, options: .mappedIfSafe) + let sealed = try AES.GCM.seal( + plaintext, + using: recoveryKey.symmetricKey, + authenticating: envelope.authenticatedData, + ) + try Task.checkCancellation() + guard let combined = sealed.combined else { + throw EncryptedBackupError.authenticationFailed + } + try combined.write( + to: staging.appendingPathComponent("archive.aesgcm"), + options: .atomic, + ) + + try Task.checkCancellation() + let destination = root + .appendingPathComponent(Self.automaticArchiveName(for: exportedAt)) + try fileManager.zipItem( + at: staging, + to: destination, + shouldKeepParent: false, + compressionMethod: .none, + progress: Self.cancellationProgress, + ) + try Task.checkCancellation() + return destination + } catch { + Self.removeEncryptedStagingDirectory(root) + throw error + } + } + + /// Reads and decrypts a `.wherebackup` before decoding its unchanged inner + /// `BackupArchive`. No store mutation occurs in this layer. + public func readEncryptedArchive( + at url: URL, + recoveryKey: BackupRecoveryKey, + ) throws -> ReadResult { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent( + "where-encrypted-import-\(UUID().uuidString)", + isDirectory: true, + ) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { Self.removeEncryptedStagingDirectory(root) } + + try Task.checkCancellation() + try fileManager.unzipItem(at: url, to: root, progress: Self.cancellationProgress) + let envelopeURL = root.appendingPathComponent("envelope.json") + guard fileManager.fileExists(atPath: envelopeURL.path) else { + throw EncryptedBackupError.envelopeMissing + } + let envelope = try Self.makeDecoder().decode( + EncryptedBackupEnvelope.self, + from: Data(contentsOf: envelopeURL), + ) + try validate(envelope) + guard envelope.keyIdentifier == recoveryKey.identifier else { + throw EncryptedBackupError.recoveryKeyMismatch + } + + let payloadURL = root.appendingPathComponent("archive.aesgcm") + guard fileManager.fileExists(atPath: payloadURL.path) else { + throw EncryptedBackupError.encryptedArchiveMissing + } + let combined = try Data(contentsOf: payloadURL, options: .mappedIfSafe) + let sealed = try AES.GCM.SealedBox(combined: combined) + let plaintext: Data + do { + plaintext = try AES.GCM.open( + sealed, + using: recoveryKey.symmetricKey, + authenticating: envelope.authenticatedData, + ) + } catch { + throw EncryptedBackupError.authenticationFailed + } + try Task.checkCancellation() + let innerURL = root.appendingPathComponent("archive.zip") + try plaintext.write(to: innerURL, options: .atomic) + return try readArchive(at: innerURL) + } + + public func readEncryptedEnvelope(at url: URL) throws -> EncryptedBackupEnvelope { + guard let archive = Archive(url: url, accessMode: .read), + let entry = archive["envelope.json"] + else { + throw EncryptedBackupError.envelopeMissing + } + var data = Data() + _ = try archive.extract(entry) { data.append($0) } + let envelope = try Self.makeDecoder().decode(EncryptedBackupEnvelope.self, from: data) + try validate(envelope) + return envelope + } + + private func validate(_ envelope: EncryptedBackupEnvelope) throws { + guard envelope.version == EncryptedBackupEnvelope.currentVersion else { + throw EncryptedBackupError.unsupportedEnvelopeVersion(envelope.version) + } + guard envelope.protection == EncryptedBackupEnvelope.aesGCM256 else { + throw EncryptedBackupError.unsupportedProtection(envelope.protection) + } + } + + private static func automaticArchiveName(for date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH.mm.ss" + return "Where Automatic Backup \(formatter.string(from: date)) \(UUID().uuidString).wherebackup" + } + + private static func removeEncryptedStagingDirectory(_ url: URL) { + guard FileManager.default.fileExists(atPath: url.path) else { return } + do { + try FileManager.default.removeItem(at: url) + } catch { + WhereLog.backup(AutomaticBackupLog.self) { + .cleanupFailed(description: error.localizedDescription) + } + } + } +} diff --git a/Where/WhereCore/Sources/Logging/AutomaticBackupLog.swift b/Where/WhereCore/Sources/Logging/AutomaticBackupLog.swift new file mode 100644 index 000000000..62b45478a --- /dev/null +++ b/Where/WhereCore/Sources/Logging/AutomaticBackupLog.swift @@ -0,0 +1,31 @@ +import PeriscopeCore + +enum AutomaticBackupLog: LogEvent { + case iCloudUnavailable + case iCloudAccessFailed(description: String) + case ignoredUnrecognizedFile(name: String) + case cleanupFailed(description: String) + + static let eventName = "AutomaticBackup" + + var level: LogLevel { + switch self { + case .iCloudUnavailable, .iCloudAccessFailed, .ignoredUnrecognizedFile, + .cleanupFailed: + .warning + } + } + + var message: String { + switch self { + case .iCloudUnavailable: + "iCloud Drive is unavailable; using local backup storage" + case let .iCloudAccessFailed(description): + "iCloud backup access failed; using local storage: \(description)" + case let .ignoredUnrecognizedFile(name): + "Ignored unrecognized automatic-backup file: \(name)" + case let .cleanupFailed(description): + "Automatic-backup staging cleanup failed: \(description)" + } + } +} diff --git a/Where/WhereCore/Sources/Logging/BackupServiceLog.swift b/Where/WhereCore/Sources/Logging/BackupServiceLog.swift index 4d7515590..b2f437f12 100644 --- a/Where/WhereCore/Sources/Logging/BackupServiceLog.swift +++ b/Where/WhereCore/Sources/Logging/BackupServiceLog.swift @@ -29,13 +29,14 @@ enum BackupServiceLog: LogEvent { trackedRegionCount: Int, ) case assetMissing(evidenceID: String) + case stagingCleanupFailed(description: String) static let eventName = "BackupService" var level: LogLevel { switch self { case .wroteBackup: .info - case .assetMissing: .warning + case .assetMissing, .stagingCleanupFailed: .warning } } @@ -51,13 +52,15 @@ enum BackupServiceLog: LogEvent { "Wrote backup with \(sampleCount) samples, \(evidenceCount) evidence, \(manualDayCount) manual days, \(dismissedIssueCount) dismissals, \(trackedRegionCount) tracked regions" case let .assetMissing(evidenceID): "Backup asset missing for evidence \(evidenceID); skipping blob" + case let .stagingCleanupFailed(description): + "Could not remove backup staging directory: \(description)" } } var externalID: String? { switch self { case let .assetMissing(evidenceID): WhereStoreID.evidence(evidenceID) - case .wroteBackup: nil + case .stagingCleanupFailed, .wroteBackup: nil } } @@ -92,7 +95,7 @@ enum BackupServiceLog: LogEvent { value: .count(trackedRegionCount), ), ] - case .assetMissing: + case .assetMissing, .stagingCleanupFailed: [] } } diff --git a/Where/WhereCore/Sources/Preferences/WherePreferences.swift b/Where/WhereCore/Sources/Preferences/WherePreferences.swift index c25bec45c..4387dca47 100644 --- a/Where/WhereCore/Sources/Preferences/WherePreferences.swift +++ b/Where/WhereCore/Sources/Preferences/WherePreferences.swift @@ -18,6 +18,12 @@ import RegionKit /// read/written eagerly; callers that need observation (SwiftUI) mirror them in /// their own observable state. public final class WherePreferences { + public struct ResetGeneration: Hashable, Sendable { + fileprivate let value = UUID() + } + + /// Pending operations must not publish results into a reset installation. + public private(set) var resetGeneration = ResetGeneration() private let store: any KeyValueStore private let invalidValue: (String) -> Void @@ -130,6 +136,36 @@ public final class WherePreferences { set { store.set(newValue, forKey: Keys.issueAlertsEnabled.rawValue) } } + /// The user's automatic-backup intent. Recording state gates its effective + /// behavior without erasing this choice, so re-enabling recording restores it. + public var automaticBackupsEnabled: Bool { + get { store.object(forKey: Keys.automaticBackupsEnabled.rawValue) as? Bool ?? true } + set { store.set(newValue, forKey: Keys.automaticBackupsEnabled.rawValue) } + } + + /// The requested automatic-backup cadence. Defaults to weekly. + public var automaticBackupInterval: AutomaticBackupInterval { + get { + guard let rawValue = store + .object(forKey: Keys.automaticBackupInterval.rawValue) as? String + else { return .weekly } + return AutomaticBackupInterval(rawValue: rawValue) ?? .weekly + } + set { store.set(newValue.rawValue, forKey: Keys.automaticBackupInterval.rawValue) } + } + + /// The last fully written automatic backup. Failed or deferred attempts do + /// not advance this timestamp. + public var lastAutomaticBackupAt: Date? { + get { store.object(forKey: Keys.lastAutomaticBackupAt.rawValue) as? Date } + set { store.set(newValue, forKey: Keys.lastAutomaticBackupAt.rawValue) } + } + + public func recordAutomaticBackupSuccess(at date: Date, generation: ResetGeneration) { + guard generation == resetGeneration else { return } + lastAutomaticBackupAt = max(lastAutomaticBackupAt ?? date, date) + } + /// The user's saved, vendor-neutral diagnostic-reporting choices. public var diagnosticReportingConfiguration: DiagnosticReportingConfiguration { get { diagnosticReportingConfiguration(isDebugBuild: Self.isDebugBuild) } @@ -228,6 +264,7 @@ public final class WherePreferences { /// Removing the keys (rather than writing `false`/`0`) lets the /// default-valued getters report first-install state again. public func reset() { + resetGeneration = ResetGeneration() for key in Keys.allCases { store.removeObject(forKey: key.rawValue) } @@ -248,6 +285,9 @@ public final class WherePreferences { case summaryHour = "where.summaryHour" case summaryMinute = "where.summaryMinute" case issueAlertsEnabled = "where.issueAlertsEnabled" + case automaticBackupsEnabled = "where.automaticBackupsEnabled" + case automaticBackupInterval = "where.automaticBackupInterval" + case lastAutomaticBackupAt = "where.lastAutomaticBackupAt" case diagnosticReportingConfiguration = "where.diagnostics.configuration" case recordingConfigurationWarningRegistration = "where.recordingConfigurationWarningRegistration" diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index abe7cb7f1..d1d73a8a3 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -58,6 +58,9 @@ public struct WhereServices: Sendable { public let journal: DayJournal /// Backup export / import. public let backup: BackupCoordinator + /// Encrypted automatic backup orchestration. Nil only in test, preview, + /// demo, and App Intents stacks that deliberately have no device storage. + public let automaticBackups: AutomaticBackupService? /// The single synced “I’ll be here through…” intent used by location /// forecasts. public let plannedStays: PlannedStayCoordinator @@ -122,6 +125,10 @@ public struct WhereServices: Sendable { locationOutbox: any LocationOutbox = NoOpLocationOutbox(), importRecoveryPersistence: any BackupImportRecoveryPersisting = NoopBackupImportRecoveryPersistence(), + backupRecoveryKeys: BackupRecoveryKeyProvider? = nil, + automaticBackupStorage: AutomaticBackupStorage? = nil, + automaticBackupScheduler: any AutomaticBackupTaskScheduling = + NoopAutomaticBackupTaskScheduler(), now: @escaping @Sendable () -> Date = { Date() }, ) { let currentDevice = installationContext.currentDevice @@ -285,6 +292,18 @@ public struct WhereServices: Sendable { self.recording = recording self.journal = journal self.backup = backup + if let backupRecoveryKeys, let automaticBackupStorage { + automaticBackups = AutomaticBackupService( + backup: backup, + recoveryKeys: backupRecoveryKeys, + storage: automaticBackupStorage, + scheduler: automaticBackupScheduler, + calendar: aggregator.calendar, + now: now, + ) + } else { + automaticBackups = nil + } self.plannedStays = plannedStays self.plannedStayLocation = plannedStayLocation self.resolution = resolution @@ -321,6 +340,10 @@ public struct WhereServices: Sendable { widgetRefresher: any WidgetTimelineRefreshing, locationOutbox: any LocationOutbox = NoOpLocationOutbox(), importRecoveryPersistence: any BackupImportRecoveryPersisting, + backupRecoveryKeys: BackupRecoveryKeyProvider? = nil, + automaticBackupStorage: AutomaticBackupStorage? = nil, + automaticBackupScheduler: any AutomaticBackupTaskScheduling = + NoopAutomaticBackupTaskScheduler(), now: @escaping @Sendable () -> Date = { Date() }, ) async throws -> WhereServices { let tracked = try await store.trackedRegions() @@ -344,6 +367,9 @@ public struct WhereServices: Sendable { widgetRefresher: widgetRefresher, locationOutbox: locationOutbox, importRecoveryPersistence: importRecoveryPersistence, + backupRecoveryKeys: backupRecoveryKeys, + automaticBackupStorage: automaticBackupStorage, + automaticBackupScheduler: automaticBackupScheduler, now: now, ) } @@ -392,13 +418,21 @@ public struct WhereServices: Sendable { /// exact old authority and backlog. Throws on persistence failure so the caller can surface /// it rather than silently half-erasing. public func reset() async throws { - try await recording.pause() + await automaticBackups?.suspend() + do { + try await recording.pause() + } catch { + await automaticBackups?.resume() + throw error + } do { try await journal.eraseAllData() } catch { await recording.resumeAfterFailedReset() + await automaticBackups?.resume() throw error } + await automaticBackups?.shutDown() // The erase committed even if sidecar cleanup below fails. Refresh every derived // projection that `DayJournal` does not already own before reporting that partial result. await resolution.invalidate() diff --git a/Where/WhereCore/Tests/AutomaticBackupFileAvailabilityTests.swift b/Where/WhereCore/Tests/AutomaticBackupFileAvailabilityTests.swift new file mode 100644 index 000000000..a9b5f9a37 --- /dev/null +++ b/Where/WhereCore/Tests/AutomaticBackupFileAvailabilityTests.swift @@ -0,0 +1,12 @@ +import Foundation +import Testing +@testable import WhereCore + +struct AutomaticBackupFileAvailabilityTests { + @Test func anOrdinaryLocalFileNeedsNoDownload() throws { + let file = URL.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data([1]).write(to: file) + defer { try? FileManager.default.removeItem(at: file) } + #expect(try SystemAutomaticBackupFileAvailability().isDownloaded(at: file)) + } +} diff --git a/Where/WhereCore/Tests/AutomaticBackupIntervalTests.swift b/Where/WhereCore/Tests/AutomaticBackupIntervalTests.swift new file mode 100644 index 000000000..f3f3567c0 --- /dev/null +++ b/Where/WhereCore/Tests/AutomaticBackupIntervalTests.swift @@ -0,0 +1,29 @@ +import Foundation +import Testing +@testable import WhereCore + +struct AutomaticBackupIntervalTests { + @Test func dailyWeeklyAndCalendarMonthlyDates() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let start = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 1, + day: 31, + hour: 12, + ))) + + #expect(calendar.dateComponents( + [.day], + from: start, + to: AutomaticBackupInterval.daily.nextDate(after: start, calendar: calendar), + ).day == 1) + #expect(calendar.dateComponents( + [.day], + from: start, + to: AutomaticBackupInterval.weekly.nextDate(after: start, calendar: calendar), + ).day == 7) + let monthly = AutomaticBackupInterval.monthly.nextDate(after: start, calendar: calendar) + #expect(calendar.component(.month, from: monthly) == 2) + } +} diff --git a/Where/WhereCore/Tests/AutomaticBackupRetentionTests.swift b/Where/WhereCore/Tests/AutomaticBackupRetentionTests.swift new file mode 100644 index 000000000..3bd34aad4 --- /dev/null +++ b/Where/WhereCore/Tests/AutomaticBackupRetentionTests.swift @@ -0,0 +1,50 @@ +import CryptoKit +import Foundation +import Testing +@_spi(Testing) @testable import WhereCore + +struct AutomaticBackupRetentionTests { + @Test(arguments: [0, 1, 2, 3]) + func aChangedCandidateOrKeeperPreventsDeletion(changedIndex: Int) async throws { + let fixture = try AutomaticBackupStorageFixture() + defer { try? fixture.cleanup() } + let verified = try await fixture.makeVerifiedFiles(count: 4) + try Data("changed after authentication".utf8).write(to: verified[changedIndex].file.url) + try await AutomaticBackupRetention(verified: verified, retainedFileCount: 3).prune() + #expect(verified.allSatisfy { FileManager.default.fileExists(atPath: $0.file.url.path) }) + } + + @Test func aMissingKeeperPreservesTheOldestRecoverableFile() async throws { + let fixture = try AutomaticBackupStorageFixture() + defer { try? fixture.cleanup() } + let verified = try await fixture.makeVerifiedFiles(count: 4) + try FileManager.default.removeItem(at: verified[1].file.url) + await #expect(throws: CocoaError.self) { + try await AutomaticBackupRetention(verified: verified, retainedFileCount: 3).prune() + } + #expect(FileManager.default.fileExists(atPath: verified[0].file.url.path)) + } + + @Test func unchangedAuthenticatedKeepersAuthorizePruning() async throws { + let fixture = try AutomaticBackupStorageFixture() + defer { try? fixture.cleanup() } + let verified = try await fixture.makeVerifiedFiles(count: 5) + try await AutomaticBackupRetention(verified: verified, retainedFileCount: 3).prune() + #expect(verified.prefix(2) + .allSatisfy { FileManager.default.fileExists(atPath: $0.file.url.path) == false }) + #expect(verified.suffix(3) + .allSatisfy { FileManager.default.fileExists(atPath: $0.file.url.path) }) + } + + @Test func cancelledPruningDeletesNothing() async throws { + let fixture = try AutomaticBackupStorageFixture() + defer { try? fixture.cleanup() } + let verified = try await fixture.makeVerifiedFiles(count: 4) + let operation = Task { + withUnsafeCurrentTask { $0?.cancel() } + try await AutomaticBackupRetention(verified: verified, retainedFileCount: 3).prune() + } + await #expect(throws: CancellationError.self) { try await operation.value } + #expect(verified.allSatisfy { FileManager.default.fileExists(atPath: $0.file.url.path) }) + } +} diff --git a/Where/WhereCore/Tests/AutomaticBackupServiceTests.swift b/Where/WhereCore/Tests/AutomaticBackupServiceTests.swift new file mode 100644 index 000000000..f17c5a523 --- /dev/null +++ b/Where/WhereCore/Tests/AutomaticBackupServiceTests.swift @@ -0,0 +1,347 @@ +import Foundation +@_spi(Testing) import KeychainKit +import Testing +@_spi(Testing) @testable import WhereCore + +struct AutomaticBackupServiceTests { + @Test func cancellingAViewCallerDoesNotCancelTheSharedExport() async throws { + let fixture = try AutomaticBackupStorageFixture() + defer { try? fixture.cleanup() } + let gate = BackupAccessGate() + let now = Date(timeIntervalSince1970: 1_700_000_000) + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + backupRecoveryKeys: BackupRecoveryKeyProvider( + store: InMemoryKeychainStore(), + isProtectedDataAvailable: { await gate.wait() }, + ), + automaticBackupStorage: AutomaticBackupStorage( + iCloudRoot: { nil }, + localRoot: { fixture.root }, + ), + now: { now }, + ) + let automatic = try #require(services.automaticBackups) + let configuration = AutomaticBackupConfiguration( + isEnabled: true, + isRecordingEnabled: true, + interval: .weekly, + lastSuccessfulBackupAt: nil, + ) + let view = Task { try await automatic.runIfDue( + cancellation: .finishExecution, + configuration: configuration, + ) } + await gate.waitForArrival() + let background = Task { try await automatic.runIfDue( + cancellation: .cancelExecution, + configuration: configuration, + ) } + view.cancel() + await gate.release() + #expect(try await view.value == .completed(exportedAt: now)) + let backgroundResult = try await background.value + // Either joins the flight or arrives after it committed. Both preserve + // the one archive and avoid a second export. + #expect(backgroundResult == .completed(exportedAt: now) || backgroundResult == + .notDue(nextEligibleAt: AutomaticBackupInterval.weekly.nextDate(after: now))) + #expect(try await automatic.catalog().files.count == 1) + } + + @Test func shutdownDrainsAnInFlightScheduleBeforeReturning() async throws { + let fixture = try AutomaticBackupStorageFixture() + defer { try? fixture.cleanup() } + let gate = BackupAccessGate() + let scheduler = GatedAutomaticBackupScheduler(gate: gate) + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + backupRecoveryKeys: fixture.keys, + automaticBackupStorage: AutomaticBackupStorage( + iCloudRoot: { nil }, + localRoot: { fixture.root }, + ), + automaticBackupScheduler: scheduler, + ) + let automatic = try #require(services.automaticBackups) + let initial = Task { + await automatic.reconcileSchedule(configuration: .init( + isEnabled: true, + isRecordingEnabled: true, + interval: .weekly, + lastSuccessfulBackupAt: nil, + )) + } + await gate.waitForArrival() + let shutdown = Task { + await automatic.shutDown() + await scheduler.didShutDown() + } + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while await !automatic.isRetiredForTesting, ContinuousClock.now < deadline { + await Task.yield() + } + #expect(await automatic.isRetiredForTesting) + await gate.release() + await initial.value + await shutdown.value + #expect(await scheduler.events == [.reconciled(true), .reconciled(false), .shutDown]) + } + + @Test func intervalChangeDuringExportUsesTheNewIntervalAfterSuccess() async throws { + let fixture = try AutomaticBackupStorageFixture() + defer { try? fixture.cleanup() } + let gate = BackupAccessGate() + let scheduler = SpyAutomaticBackupScheduler() + let now = Date(timeIntervalSince1970: 1_700_000_000) + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + backupRecoveryKeys: BackupRecoveryKeyProvider( + store: InMemoryKeychainStore(), + isProtectedDataAvailable: { await gate.wait() }, + ), + automaticBackupStorage: AutomaticBackupStorage( + iCloudRoot: { nil }, + localRoot: { fixture.root }, + ), + automaticBackupScheduler: scheduler, + now: { now }, + ) + let automatic = try #require(services.automaticBackups) + let operation = Task { + try await automatic.runIfDue(cancellation: .cancelExecution, configuration: .init( + isEnabled: true, + isRecordingEnabled: true, + interval: .weekly, + lastSuccessfulBackupAt: nil, + )) + } + await gate.waitForArrival() + await automatic.reconcileSchedule(configuration: .init( + isEnabled: true, + isRecordingEnabled: true, + interval: .monthly, + lastSuccessfulBackupAt: nil, + )) + await gate.release() + #expect(try await operation.value == .completed(exportedAt: now)) + #expect(await scheduler.latest?.earliestBeginDate == AutomaticBackupInterval.monthly + .nextDate(after: now)) + } + + @Test func suspensionCanResumeButRetiredScopesCannotExportOrSchedule() async throws { + let fixture = try AutomaticBackupStorageFixture() + defer { try? fixture.cleanup() } + let scheduler = SpyAutomaticBackupScheduler() + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + backupRecoveryKeys: fixture.keys, + automaticBackupStorage: AutomaticBackupStorage( + iCloudRoot: { nil }, + localRoot: { fixture.root }, + ), + automaticBackupScheduler: scheduler, + ) + let automatic = try #require(services.automaticBackups) + let configuration = AutomaticBackupConfiguration( + isEnabled: true, + isRecordingEnabled: true, + interval: .weekly, + lastSuccessfulBackupAt: nil, + ) + await automatic.reconcileSchedule(configuration: configuration) + await automatic.suspend() + #expect(await scheduler.latest?.isEnabled == false) + #expect(try await automatic.runIfDue( + cancellation: .cancelExecution, + configuration: configuration, + ) == .disabled) + await automatic.resume() + #expect(await scheduler.latest?.isEnabled == true) + await automatic.shutDown() + await automatic.resume() + #expect(try await automatic.runIfDue( + cancellation: .cancelExecution, + configuration: configuration, + ) == .disabled) + #expect(await scheduler.latest?.isEnabled == false) + #expect(try await automatic.catalog().files.isEmpty) + } + + @Test func disablingDuringKeyAccessCancelsTheExportAndKeepsSchedulingDisabled() async throws { + let fixture = try AutomaticBackupStorageFixture() + defer { try? fixture.cleanup() } + let gate = BackupAccessGate() + let scheduler = SpyAutomaticBackupScheduler() + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + backupRecoveryKeys: BackupRecoveryKeyProvider( + store: InMemoryKeychainStore(), + isProtectedDataAvailable: { await gate.wait() }, + ), + automaticBackupStorage: AutomaticBackupStorage( + iCloudRoot: { nil }, + localRoot: { fixture.root }, + ), + automaticBackupScheduler: scheduler, + ) + let automatic = try #require(services.automaticBackups) + let operation = Task { + try await automatic.runIfDue(cancellation: .cancelExecution, configuration: .init( + isEnabled: true, + isRecordingEnabled: true, + interval: .weekly, + lastSuccessfulBackupAt: nil, + )) + } + await gate.waitForArrival() + await automatic.reconcileSchedule(configuration: .init( + isEnabled: false, + isRecordingEnabled: true, + interval: .monthly, + lastSuccessfulBackupAt: nil, + )) + await gate.release() + await #expect(throws: CancellationError.self) { try await operation.value } + #expect(await scheduler.latest?.isEnabled == false) + #expect(try await automatic.catalog().files.isEmpty) + } + + @Test func expirationCancelsTheOwnedOperationWithoutRecordingSuccess() async throws { + let fixture = try AutomaticBackupStorageFixture() + defer { try? fixture.cleanup() } + let gate = BackupAccessGate() + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + backupRecoveryKeys: BackupRecoveryKeyProvider( + store: InMemoryKeychainStore(), + isProtectedDataAvailable: { await gate.wait() }, + ), + automaticBackupStorage: AutomaticBackupStorage( + iCloudRoot: { nil }, + localRoot: { fixture.root }, + ), + ) + let automatic = try #require(services.automaticBackups) + let operation = Task { + try await automatic.runIfDue(cancellation: .cancelExecution, configuration: .init( + isEnabled: true, + isRecordingEnabled: true, + interval: .weekly, + lastSuccessfulBackupAt: nil, + )) + } + await gate.waitForArrival() + operation.cancel() + await gate.release() + await #expect(throws: CancellationError.self) { try await operation.value } + #expect(try await automatic.catalog().files.isEmpty) + } + + @Test func firstRunIsImmediateAndTheNextRunUsesTheLastSuccess() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "automatic-backup-service-\(UUID().uuidString)", + isDirectory: true, + ) + defer { try? FileManager.default.removeItem(at: root) } + let now = Date(timeIntervalSince1970: 1_700_000_000) + let scheduler = SpyAutomaticBackupScheduler() + let keys = BackupRecoveryKeyProvider(store: InMemoryKeychainStore()) { true } + let storage = AutomaticBackupStorage( + iCloudRoot: { nil }, + localRoot: { root }, + ) + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + backupRecoveryKeys: keys, + automaticBackupStorage: storage, + automaticBackupScheduler: scheduler, + now: { now }, + ) + let automatic = try #require(services.automaticBackups) + let configuration = AutomaticBackupConfiguration( + isEnabled: true, + isRecordingEnabled: true, + interval: .weekly, + lastSuccessfulBackupAt: nil, + ) + + #expect(try await automatic.runIfDue( + cancellation: .cancelExecution, + configuration: configuration, + ) == .completed( + exportedAt: now, + )) + let catalog = try await automatic.catalog() + #expect(catalog.files.count == 1) + + let afterSuccess = AutomaticBackupConfiguration( + isEnabled: true, + isRecordingEnabled: true, + interval: .weekly, + lastSuccessfulBackupAt: now, + ) + let next = AutomaticBackupInterval.weekly.nextDate(after: now) + #expect(try await automatic.runIfDue( + cancellation: .cancelExecution, + configuration: afterSuccess, + ) == .notDue( + nextEligibleAt: next, + )) + #expect(await scheduler.latest?.isEnabled == true) + // A caller's stale preference snapshot must not produce another file. + #expect(try await automatic + .runIfDue(cancellation: .cancelExecution, configuration: configuration) == + .notDue(nextEligibleAt: next)) + } + + @Test func lockedKeyDefersBeforeExport() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "automatic-backup-locked-\(UUID().uuidString)", + isDirectory: true, + ) + defer { try? FileManager.default.removeItem(at: root) } + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + backupRecoveryKeys: BackupRecoveryKeyProvider( + store: InMemoryKeychainStore(data: Data(repeating: 1, count: 32)), + isProtectedDataAvailable: { false }, + ), + automaticBackupStorage: AutomaticBackupStorage( + iCloudRoot: { nil }, + localRoot: { root }, + ), + ) + let automatic = try #require(services.automaticBackups) + + #expect(try await automatic.runIfDue(cancellation: .cancelExecution, configuration: .init( + isEnabled: true, + isRecordingEnabled: true, + interval: .weekly, + lastSuccessfulBackupAt: nil, + )) == .deferredUntilFirstUnlock) + #expect(!FileManager.default.fileExists(atPath: root.path)) + } +} + +private actor SpyAutomaticBackupScheduler: AutomaticBackupTaskScheduling { + struct Reconciliation { + let isEnabled: Bool + let earliestBeginDate: Date? + } + + private(set) var latest: Reconciliation? + + func reconcile(isEnabled: Bool, earliestBeginDate: Date?) { + latest = Reconciliation(isEnabled: isEnabled, earliestBeginDate: earliestBeginDate) + } +} diff --git a/Where/WhereCore/Tests/AutomaticBackupStorageTests.swift b/Where/WhereCore/Tests/AutomaticBackupStorageTests.swift new file mode 100644 index 000000000..e1d00e7b1 --- /dev/null +++ b/Where/WhereCore/Tests/AutomaticBackupStorageTests.swift @@ -0,0 +1,193 @@ +import Foundation +@_spi(Testing) import KeychainKit +import Testing +import ZIPFoundation +@_spi(Testing) @testable import WhereCore + +struct AutomaticBackupStorageTests { + @Test(.timeLimit(.minutes(1))) + func blockedCatalogDoesNotOwnTheStorageActorAndReceivesCancellation() async throws { + let fixture = try AutomaticBackupStorageFixture() + defer { try? fixture.cleanup() } + let blockedURL = fixture.root.appendingPathComponent("blocked.wherebackup") + try Data([1]).write(to: blockedURL) + let availability = BlockingBackupFileAvailability(blockedURL: blockedURL) + defer { availability.release() } + let storage = AutomaticBackupStorage( + iCloudRoot: { fixture.root }, + localRoot: { fixture.root.appendingPathComponent("local") }, + availability: availability, + ) + let read = Task { try await storage.catalog() } + defer { read.cancel() } + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while !availability.hasArrived, ContinuousClock.now < deadline { + await Task.yield() + } + try #require(availability.hasArrived) + let source = try fixture.makeArchive(at: Date(timeIntervalSince1970: 1)) + defer { try? FileManager.default.removeItem(at: source.deletingLastPathComponent()) } + let stored = try await storage.store(source) + #expect(FileManager.default.fileExists(atPath: stored.url.path)) + read.cancel() + await #expect(throws: CancellationError.self) { try await read.value } + } + + @Test func evictedCloudFilesDoNotHideAccessibleCloudAndLocalBackups() async throws { + let fixture = try AutomaticBackupStorageFixture() + defer { try? fixture.cleanup() } + let cloud = fixture.root.appendingPathComponent("cloud") + let local = fixture.root.appendingPathComponent("local") + let writer = AutomaticBackupStorage(iCloudRoot: { cloud }, localRoot: { local }) + let localWriter = AutomaticBackupStorage(iCloudRoot: { nil }, localRoot: { local }) + let cloudSource = try fixture.makeArchive(at: Date(timeIntervalSince1970: 2)) + defer { try? FileManager.default.removeItem(at: cloudSource.deletingLastPathComponent()) } + let localSource = try fixture.makeArchive(at: Date(timeIntervalSince1970: 1)) + defer { try? FileManager.default.removeItem(at: localSource.deletingLastPathComponent()) } + let cloudFile = try await writer.store(cloudSource) + let localFile = try await localWriter.store(localSource) + let evicted = cloud.appendingPathComponent("evicted.wherebackup") + // Unreadable contents deliberately fail if the preflight is performed + // after content access. No real iCloud account is used by this test. + try FileManager.default.createDirectory(at: evicted, withIntermediateDirectories: true) + let reader = AutomaticBackupStorage( + iCloudRoot: { cloud }, + localRoot: { local }, + availability: ScriptedBackupFileAvailability(unavailable: [evicted]), + ) + let catalog = try await reader.catalog() + #expect(catalog.isICloudUnavailable) + #expect(catalog.files.map(\.url) == [cloudFile.url, localFile.url]) + } + + @Test func cancelledCatalogIsNotReportedAsAnEmptyOrPartialSuccess() async throws { + let fixture = try AutomaticBackupStorageFixture() + defer { try? fixture.cleanup() } + let storage = AutomaticBackupStorage(iCloudRoot: { nil }, localRoot: { fixture.root }) + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try await storage.catalog() + } + await #expect(throws: CancellationError.self) { try await task.value } + } + + @Test func forgedFutureEnvelopesCannotDisplaceRecoverableBackups() async throws { + let fixture = try AutomaticBackupStorageFixture() + defer { try? fixture.cleanup() } + let local = fixture.root.appendingPathComponent("local") + let storage = AutomaticBackupStorage(iCloudRoot: { nil }, localRoot: { local }) + var validFiles: [URL] = [] + for index in 0 ..< 3 { + let archive = try fixture.makeArchive(at: Date(timeIntervalSince1970: Double(index))) + defer { try? FileManager.default.removeItem(at: archive.deletingLastPathComponent()) } + try await validFiles.append(storage.store(archive).url) + } + // Valid-looking metadata, but there is no authenticated payload. + let contents = fixture.root.appendingPathComponent("forged") + try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) + for index in 0 ..< 3 { + let envelope = EncryptedBackupEnvelope( + keyIdentifier: fixture.key.identifier, + exportedAt: Date(timeIntervalSince1970: 2_000_000_000 + Double(index)), + ) + try BackupService.makeEncoder().encode(envelope) + .write(to: contents.appendingPathComponent("envelope.json")) + try FileManager.default.zipItem( + at: contents, + to: local.appendingPathComponent("forged-\(index).wherebackup"), + shouldKeepParent: false, + ) + } + try await storage.reconcileRetention(recoveryKeys: fixture.keys) + #expect(validFiles.allSatisfy { FileManager.default.fileExists(atPath: $0.path) }) + #expect(try await storage.catalog().files.count == 6) + } + + @Test func cloudCommitIsNotRetriedLocallyWhenRetentionCannotEnumerateLocalStorage( + ) async throws { + let fixture = try AutomaticBackupStorageFixture() + defer { try? fixture.cleanup() } + let cloud = fixture.root.appendingPathComponent("cloud") + let local = fixture.root.appendingPathComponent("not-a-directory") + try Data([1]).write(to: local) + let storage = AutomaticBackupStorage(iCloudRoot: { cloud }, localRoot: { local }) + let archive = try fixture.makeArchive(at: Date()) + defer { try? FileManager.default.removeItem(at: archive.deletingLastPathComponent()) } + let stored = try await storage.store(archive) + #expect(stored.storageLocation == .iCloudDrive) + await #expect(throws: (any Error).self) { + try await storage.reconcileRetention(recoveryKeys: fixture.keys) + } + #expect(try FileManager.default.contentsOfDirectory(atPath: cloud.path).count == 1) + #expect(try Data(contentsOf: local) == Data([1])) + } + + @Test func catalogReportsCloudFailureAndThrowsForLocalFailure() async throws { + let fixture = try AutomaticBackupStorageFixture() + defer { try? fixture.cleanup() } + let invalid = fixture.root.appendingPathComponent("not-a-directory") + try Data([1]).write(to: invalid) + let partial = AutomaticBackupStorage( + iCloudRoot: { invalid }, + localRoot: { fixture.root.appendingPathComponent("empty") }, + ) + #expect(try await partial.catalog().isICloudUnavailable) + let failed = AutomaticBackupStorage(iCloudRoot: { nil }, localRoot: { invalid }) + await #expect(throws: (any Error).self) { try await failed.catalog() } + } + + @Test func fallsBackToDocumentsAndRetainsTheNewestThree() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "automatic-backup-storage-\(UUID().uuidString)", + isDirectory: true, + ) + defer { try? FileManager.default.removeItem(at: root) } + let local = root.appendingPathComponent("local", isDirectory: true) + let storage = AutomaticBackupStorage( + iCloudRoot: { nil }, + localRoot: { local }, + ) + let service = BackupService() + let key = try BackupRecoveryKey(data: Data(repeating: 42, count: 32)) + let keys = BackupRecoveryKeyProvider(store: InMemoryKeychainStore(data: key.data)) { true } + + for day in 1 ... 4 { + let date = Date(timeIntervalSince1970: 1_700_000_000 + Double(day)) + let plaintext = try service.makeArchiveFile( + samples: [], + evidence: [], + manualDays: [], + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceRemovals: [], + plannedStayRecords: [], + blobs: [:], + exportedAt: date, + ) + let encrypted = try service.makeEncryptedArchiveFile( + from: plaintext, + recoveryKey: key, + exportedAt: date, + ) + _ = try await storage.store(encrypted) + try await storage.reconcileRetention(recoveryKeys: keys) + try? FileManager.default.removeItem(at: plaintext.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: encrypted.deletingLastPathComponent()) + } + + let manualFile = local.appendingPathComponent("manual.zip") + try Data("manual".utf8).write(to: manualFile) + let unrecognizedFile = local.appendingPathComponent("foreign.wherebackup") + try Data("not a Where container".utf8).write(to: unrecognizedFile) + let catalog = try await storage.catalog() + + #expect(catalog.isICloudUnavailable) + #expect(catalog.files.count == 3) + #expect(catalog.files.allSatisfy { + $0.storageLocation == AutomaticBackupFile.StorageLocation.appDocuments + }) + #expect(FileManager.default.fileExists(atPath: manualFile.path)) + #expect(FileManager.default.fileExists(atPath: unrecognizedFile.path)) + } +} diff --git a/Where/WhereCore/Tests/AutomaticBackupTestSupport.swift b/Where/WhereCore/Tests/AutomaticBackupTestSupport.swift new file mode 100644 index 000000000..d1982833b --- /dev/null +++ b/Where/WhereCore/Tests/AutomaticBackupTestSupport.swift @@ -0,0 +1,160 @@ +import CryptoKit +import Foundation +@_spi(Testing) import KeychainKit +@_spi(Testing) @testable import WhereCore + +actor BackupAccessGate { + private var continuation: CheckedContinuation? + private var arrival: CheckedContinuation? + private var hasArrived = false + private var isOpen = false + + func wait() async -> Bool { + if isOpen { return true } + hasArrived = true + arrival?.resume() + arrival = nil + return await withCheckedContinuation { continuation = $0 } + } + + func waitForArrival() async { + if hasArrived { return } + await withCheckedContinuation { arrival = $0 } + } + + func release() { + isOpen = true + continuation?.resume(returning: true) + continuation = nil + } +} + +struct AutomaticBackupStorageFixture { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let key: BackupRecoveryKey + let keys: BackupRecoveryKeyProvider + + init() throws { + key = try BackupRecoveryKey(data: Data(repeating: 51, count: 32)) + keys = BackupRecoveryKeyProvider(store: InMemoryKeychainStore(data: key.data)) { true } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + func makeArchive(at date: Date) throws -> URL { + let service = BackupService() + let plaintext = try service.makeArchiveFile( + samples: [], + evidence: [], + manualDays: [], + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceRemovals: [], + plannedStayRecords: [], + blobs: [:], + exportedAt: date, + ) + defer { try? FileManager.default.removeItem(at: plaintext.deletingLastPathComponent()) } + return try service.makeEncryptedArchiveFile( + from: plaintext, + recoveryKey: key, + exportedAt: date, + ) + } + + func cleanup() throws { + try FileManager.default.removeItem(at: root) + } + + func makeVerifiedFiles(count: Int) async throws -> [AutomaticBackupRetention.VerifiedFile] { + let storage = AutomaticBackupStorage(iCloudRoot: { nil }, localRoot: { root }) + var verified: [AutomaticBackupRetention.VerifiedFile] = [] + for index in 0 ..< count { + let date = Date(timeIntervalSince1970: Double(index)) + let source = try makeArchive(at: date) + defer { try? FileManager.default.removeItem(at: source.deletingLastPathComponent()) } + let file = try await storage.store(source) + _ = try BackupService().readEncryptedArchive(at: file.url, recoveryKey: key) + try verified.append(AutomaticBackupRetention.VerifiedFile( + file: file, + digest: SHA256.hash(data: Data(contentsOf: file.url)), + exportedAt: date, + )) + } + return verified + } +} + +struct ScriptedBackupFileAvailability: AutomaticBackupFileAvailabilityChecking { + let unavailable: Set + + func isDownloaded(at url: URL) throws -> Bool { + !unavailable.contains(url) + } +} + +/// A synchronous I/O stall with a bounded, cancellation-aware release. +/// NSCondition protects all mutable state; no callback runs while holding it. +final class BlockingBackupFileAvailability: AutomaticBackupFileAvailabilityChecking, + @unchecked Sendable +{ + private let blockedURL: URL + private let condition = NSCondition() + private var arrived = false + private var released = false + + init(blockedURL: URL) { + self.blockedURL = blockedURL + } + + var hasArrived: Bool { + condition.lock() + defer { condition.unlock() } + return arrived + } + + func release() { + condition.lock() + released = true + condition.broadcast() + condition.unlock() + } + + func isDownloaded(at url: URL) throws -> Bool { + guard url == blockedURL else { return true } + let progress = BackupService.cancellationProgress + progress?.cancellationHandler = { self.release() } + defer { progress?.cancellationHandler = nil } + condition.lock() + defer { condition.unlock() } + arrived = true + let deadline = Date().addingTimeInterval(10) + while !released { + guard condition.wait(until: deadline) else { throw CocoaError(.fileReadUnknown) } + } + if progress?.isCancelled == true { throw CancellationError() } + return true + } +} + +actor GatedAutomaticBackupScheduler: AutomaticBackupTaskScheduling { + enum Event: Equatable { + case reconciled(Bool) + case shutDown + } + + let gate: BackupAccessGate + private(set) var events: [Event] = [] + + init(gate: BackupAccessGate) { + self.gate = gate + } + + func reconcile(isEnabled: Bool, earliestBeginDate _: Date?) async { + events.append(.reconciled(isEnabled)) + if events.count == 1 { _ = await gate.wait() } + } + + func didShutDown() { + events.append(.shutDown) + } +} diff --git a/Where/WhereCore/Tests/BackupCoordinatorTests.swift b/Where/WhereCore/Tests/BackupCoordinatorTests.swift index e2018e753..1a5f50ed2 100644 --- a/Where/WhereCore/Tests/BackupCoordinatorTests.swift +++ b/Where/WhereCore/Tests/BackupCoordinatorTests.swift @@ -1,7 +1,7 @@ import Foundation import RegionKit import Testing -@testable import WhereCore +@_spi(Testing) @testable import WhereCore /// Covers export/import round-trips and the post-commit lifecycle hook the /// coordinator invokes once new data lands. @@ -615,6 +615,29 @@ struct BackupCoordinatorTests { await harness.coordinator.discardExport() } + @Test func automaticExportPreservesOutstandingManualShare() async throws { + let harness = try Self.makeHarness() + try await Self.seed(harness.store) + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("automatic-export-test-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let storage = AutomaticBackupStorage( + iCloudRoot: { nil }, + localRoot: { root.appendingPathComponent("local", isDirectory: true) }, + ) + let recoveryKey = try BackupRecoveryKey(data: Data(repeating: 42, count: 32)) + + let manual = try await harness.coordinator.exportBackup() + _ = try await harness.coordinator.writeAutomaticBackup( + recoveryKey: recoveryKey, + exportedAt: Date(timeIntervalSince1970: 1_700_000_000), + storage: storage, + ) + + #expect(FileManager.default.fileExists(atPath: manual.path)) + await harness.coordinator.discardExport() + } + @Test func exportReportsProgressUpToCompletion() async throws { let source = try Self.makeHarness() try await Self.seed(source.store) diff --git a/Where/WhereCore/Tests/BackupRecoveryKeyProviderTests.swift b/Where/WhereCore/Tests/BackupRecoveryKeyProviderTests.swift new file mode 100644 index 000000000..384767eab --- /dev/null +++ b/Where/WhereCore/Tests/BackupRecoveryKeyProviderTests.swift @@ -0,0 +1,132 @@ +import Foundation +@_spi(Testing) import KeychainKit +import Security +import Testing +@_spi(Testing) @testable import WhereCore + +struct BackupRecoveryKeyProviderTests { + @Test func independentlyCreatedKeysRemainAvailableAfterSynchronization() async throws { + let shared = InMemoryKeychainCollection() + let first = BackupRecoveryKeyProvider( + store: InMemoryKeychainStore(), + legacyStore: InMemoryKeychainStore(), + collection: shared, + isProtectedDataAvailable: { true }, + ) + let second = BackupRecoveryKeyProvider( + store: InMemoryKeychainStore(), + legacyStore: InMemoryKeychainStore(), + collection: shared, + isProtectedDataAvailable: { true }, + ) + let firstKey = try await first.loadOrCreate() + let secondKey = try await second.loadOrCreate() + #expect(firstKey != secondKey) + #expect(try await first.loadExisting(identifier: secondKey.identifier) == secondKey) + #expect(try await second.loadExisting(identifier: firstKey.identifier) == firstKey) + #expect(try await first.loadOrCreate() == firstKey) + #expect(try await second.loadOrCreate() == secondKey) + } + + @Test func preservesLegacyKeyWithoutChangingTheSynchronizedItem() async throws { + let data = Data(repeating: 24, count: 32) + let legacy = InMemoryKeychainStore(data: data) + let active = InMemoryKeychainStore() + let collection = InMemoryKeychainCollection() + let provider = BackupRecoveryKeyProvider( + store: active, + legacyStore: legacy, + collection: collection, + isProtectedDataAvailable: { true }, + ) + let key = try await provider.loadOrCreate() + #expect(key.data == data) + #expect(try active.read() == data) + #expect(try legacy.read() == data) + #expect(collection.read(account: KeychainAccount(key.identifier)) == data) + } + + private final class SynchronizedCreationRaceStore: KeychainStore, @unchecked Sendable { + private let lock = NSLock() + private let winningData: Data + private var firstRead = true + + init(winningData: Data) { + self.winningData = winningData + } + + func read() throws -> Data? { + lock.withLock { + if firstRead { + firstRead = false + return nil + } + return winningData + } + } + + func create(_: Data) throws { + throw KeychainError(status: errSecDuplicateItem) + } + + func write(_: Data) throws { + Issue.record("The provider must use create-only semantics for a missing key.") + } + + func remove() throws {} + } + + @Test func createsAndReusesAStableKeyAfterUnlock() async throws { + let store = InMemoryKeychainStore() + let provider = BackupRecoveryKeyProvider(store: store) { true } + + let first = try await provider.loadOrCreate() + let second = try await provider.loadOrCreate() + + #expect(first == second) + #expect(Data(base64Encoded: first.base64Encoded)?.count == 32) + } + + @Test func defersWithoutReadingOrReplacingBeforeFirstUnlock() async { + let original = Data(repeating: 7, count: 32) + let store = InMemoryKeychainStore(data: original) + let provider = BackupRecoveryKeyProvider(store: store) { false } + + await #expect(throws: BackupRecoveryKeyProvider.ProviderError.deferredUntilFirstUnlock) { + try await provider.loadOrCreate() + } + #expect((try? store.read()) == original) + } + + @Test func interactionNotAllowedDefersWithoutCreatingAReplacement() async { + let store = InMemoryKeychainStore( + failure: KeychainError(status: errSecInteractionNotAllowed), + ) + let provider = BackupRecoveryKeyProvider(store: store) { true } + + await #expect(throws: BackupRecoveryKeyProvider.ProviderError.deferredUntilFirstUnlock) { + try await provider.loadOrCreate() + } + } + + @Test func malformedStoredKeyIsRejected() async { + let store = InMemoryKeychainStore(data: Data([1, 2, 3])) + let provider = BackupRecoveryKeyProvider(store: store) { true } + + await #expect(throws: BackupRecoveryKeyProvider.ProviderError.malformedKey) { + try await provider.loadOrCreate() + } + } + + @Test func synchronizedCreationRaceReusesTheWinningKey() async throws { + let winningData = Data(repeating: 17, count: 32) + let provider = BackupRecoveryKeyProvider( + store: SynchronizedCreationRaceStore(winningData: winningData), + ) { true } + + let key = try await provider.loadOrCreate() + let expected = try BackupRecoveryKey(data: winningData) + + #expect(key == expected) + } +} diff --git a/Where/WhereCore/Tests/CoordinatedBackupFileAccessTests.swift b/Where/WhereCore/Tests/CoordinatedBackupFileAccessTests.swift new file mode 100644 index 000000000..e57fa7c3e --- /dev/null +++ b/Where/WhereCore/Tests/CoordinatedBackupFileAccessTests.swift @@ -0,0 +1,27 @@ +import Foundation +import Testing +@testable import WhereCore + +struct CoordinatedBackupFileAccessTests { + @Test func accessesTheSuppliedURLAndPropagatesAccessorFailures() throws { + let root = URL.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let file = root.appendingPathComponent("archive") + let storedURL = try CoordinatedBackupFileAccess.write(at: file, options: []) { url in + try Data([1, 2, 3]).write(to: url) + return url + } + #expect(try CoordinatedBackupFileAccess + .read(at: storedURL) { try Data(contentsOf: $0) } == Data([ + 1, + 2, + 3, + ])) + #expect(throws: CocoaError.self) { + try CoordinatedBackupFileAccess.read(at: storedURL) { _ -> Data in + throw CocoaError(.fileReadNoPermission) + } + } + } +} diff --git a/Where/WhereCore/Tests/EncryptedBackupEnvelopeTests.swift b/Where/WhereCore/Tests/EncryptedBackupEnvelopeTests.swift new file mode 100644 index 000000000..3a0212767 --- /dev/null +++ b/Where/WhereCore/Tests/EncryptedBackupEnvelopeTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Testing +@testable import WhereCore + +struct EncryptedBackupEnvelopeTests { + @Test func cancelledCompressionDoesNotProduceAnArchive() throws { + let service = BackupService() + let progress = Progress(totalUnitCount: 0) + progress.cancel() + #expect(throws: (any Error).self) { + try BackupService.$cancellationProgress.withValue(progress) { + try service.makeArchiveFile( + samples: [], + evidence: [], + manualDays: [], + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceRemovals: [], + plannedStayRecords: [], + blobs: [:], + exportedAt: Date(), + ) + } + } + } + + @Test func encryptedContainerRoundTripsAndWrongKeyIsRejected() throws { + let service = BackupService() + let date = Date(timeIntervalSince1970: 1_700_000_000) + let plaintext = try service.makeArchiveFile( + samples: [], + evidence: [], + manualDays: [], + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceRemovals: [], + plannedStayRecords: [], + blobs: [:], + exportedAt: date, + ) + defer { try? FileManager.default.removeItem(at: plaintext.deletingLastPathComponent()) } + let key = try BackupRecoveryKey(data: Data(repeating: 42, count: 32)) + let encrypted = try service.makeEncryptedArchiveFile( + from: plaintext, + recoveryKey: key, + exportedAt: date, + ) + defer { try? FileManager.default.removeItem(at: encrypted.deletingLastPathComponent()) } + + let result = try service.readEncryptedArchive(at: encrypted, recoveryKey: key) + #expect(result.archive.exportedAt == date) + #expect(result.archive.formatVersion == BackupArchive.currentFormatVersion) + + let wrongKey = try BackupRecoveryKey(data: Data(repeating: 8, count: 32)) + #expect(throws: BackupService.EncryptedBackupError.recoveryKeyMismatch) { + try service.readEncryptedArchive(at: encrypted, recoveryKey: wrongKey) + } + } +} diff --git a/Where/WhereCore/Tests/WherePreferencesTests.swift b/Where/WhereCore/Tests/WherePreferencesTests.swift index f3dad643d..e607a94bb 100644 --- a/Where/WhereCore/Tests/WherePreferencesTests.swift +++ b/Where/WhereCore/Tests/WherePreferencesTests.swift @@ -4,6 +4,19 @@ import Testing @testable import WhereCore struct WherePreferencesTests { + @Test func backupCompletionsCannotRestoreResetMetadataOrMoveItBackwards() { + let preferences = preferences() + let generation = preferences.resetGeneration + let newer = Date(timeIntervalSince1970: 200) + let older = Date(timeIntervalSince1970: 100) + preferences.recordAutomaticBackupSuccess(at: newer, generation: generation) + preferences.recordAutomaticBackupSuccess(at: older, generation: generation) + #expect(preferences.lastAutomaticBackupAt == newer) + preferences.reset() + preferences.recordAutomaticBackupSuccess(at: newer, generation: generation) + #expect(preferences.lastAutomaticBackupAt == nil) + } + private func preferences() -> WherePreferences { WherePreferences(store: InMemoryKeyValueStore()) } @@ -20,6 +33,9 @@ struct WherePreferencesTests { #expect(preferences.summaryEnabled) #expect(preferences.summaryTime == .defaultMorning) #expect(preferences.issueAlertsEnabled) + #expect(preferences.automaticBackupsEnabled) + #expect(preferences.automaticBackupInterval == .weekly) + #expect(preferences.lastAutomaticBackupAt == nil) #expect( preferences.recordingConfigurationWarningRegistration == RecordingConfigurationWarningRegistration(), @@ -187,6 +203,9 @@ struct WherePreferencesTests { preferences.summaryEnabled = false preferences.summaryTime = ReminderTime(hour: 17, minute: 45) preferences.issueAlertsEnabled = false + preferences.automaticBackupsEnabled = false + preferences.automaticBackupInterval = .monthly + preferences.lastAutomaticBackupAt = Date(timeIntervalSince1970: 1_700_000_000) var recordingWarning = preferences.recordingConfigurationWarningRegistration recordingWarning.register(isWarningConditionActive: true) recordingWarning.acknowledgeCurrentGeneration() @@ -213,6 +232,9 @@ struct WherePreferencesTests { #expect(preferences.summaryEnabled) #expect(preferences.summaryTime == .defaultMorning) #expect(preferences.issueAlertsEnabled) + #expect(preferences.automaticBackupsEnabled) + #expect(preferences.automaticBackupInterval == .weekly) + #expect(preferences.lastAutomaticBackupAt == nil) #expect( preferences.recordingConfigurationWarningRegistration == RecordingConfigurationWarningRegistration(), diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 90d0b3154..be31b6321 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -24,6 +24,8 @@ Layering, localization, preview, and testing conventions live in the feature - 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`. + Defer its file access until the shared launch plan passes first unlock + (`InstallationRecordingContextStoreTests` / `PrepareProtectedDataStepTests`). - Persist the installation identity, recording choice with its current-On timestamp, stable profile/policy IDs and timestamps, two-phase backup-import recovery, and the independent terminal onboarding-import tombstone together @@ -37,8 +39,12 @@ Layering, localization, preview, and testing conventions live in the feature handoff or recording. Reconcile onboarding imports before offering Restore. Acknowledge their preference independently of cleanup. Retain the marker through any failure (`WhereLaunchTests`). -- Keep backup import onboarding-only. Settings exports archives but never - starts or resumes an import (`BackupModelTests`). +- Keep backup import onboarding-only. Settings exports archives and presents automatic-backup + status but never starts or resumes an import (`BackupModelTests`). +- Invalidate pending recovery-key reveals when the Data page hides its key. + A late response must not reveal it again (`BackupModelTests`). + Snapshot the shared `BackupSettingsContent`; test lifecycle hiding through + `BackupSettingsSection` and `BackupModel` rather than capture rehosting. - Keep diagnostic reporting's saved, process-effective, applying, and failed states distinct. Crash and replay choices stay pending until relaunch. Remote-log revisions apply live. A runtime failure invalidates in-flight @@ -103,6 +109,7 @@ Layering, localization, preview, and testing conventions live in the feature owns log retention. `LogHistoryPruner` bounds the store by age *and* event count. Both bounds are load-bearing. An age window alone leaves a heavy-logging device unbounded inside it. + Keep user waits (first unlock and onboarding) outside work budgets. - A compact form `DatePicker` goes through `WhereDatePicker` ([`Sources/Shared/WhereDatePicker.swift`](Sources/Shared/WhereDatePicker.swift)). It substitutes a deterministic stand-in under capture. The live control diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index db9be8d7b..655087b9c 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -73,13 +73,13 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's developer relaunches. The Logs destination is always present. Before its durable store is ready it reports whether the open is still running, unavailable, or failed with the actual error. -- **`WhereLaunch`** — the launch, reset, and exit-demo plans themselves. Every +- **`WhereLaunch`** — the launch, reset, and exit-demo plans themselves. First-unlock + preparation precedes demo activation and onboarding. Every work step declares a budget (`BudgetedLaunchStep`) and joins the 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. First-unlock and onboarding waits are unmeasured because they wait on the user. - **`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 @@ -157,7 +157,13 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's that onboarding marker before handing services to App Intents or registering the recording device, so Replace cleanup finishes before GPS can reopen or drain an obsolete outbox. `OnboardingImportRecoveryModel` owns that reconciliation rather than - the process-wide `WhereModel`. Settings offers export only. + the process-wide `WhereModel`. Encrypted `.wherebackup` files select the synchronized key by envelope identifier and + prompt for the copied recovery key if needed. Settings offers manual export plus automatic-backup + cadence, recovery-key, and read-only catalog controls; restore remains onboarding-only. + Hiding the recovery key also invalidates pending reveals, so a late Keychain + response cannot expose the key after the page closes or the scene becomes inactive. + `BackupSettingsSection` owns these lifecycle actions. Its shared display child, + `BackupSettingsContent`, lets snapshots pin visible states without simulating scene changes. - **`RegionPickerView` / `RegionCustomizeView`** — the shared primary-region picker (segmented map/list) and per-region color/emoji/icon customization, backed by `PrimaryRegionSelectionModel`. Reused by onboarding and the Settings @@ -403,6 +409,11 @@ suite per view, so each view's references live in their own `__Snapshots__/` directory. They build as this module's own `WhereUISnapshotTests` bundle, which runs alongside the other modules' image suites in the shared `StuffSnapshotTests` scheme and its CI job. + +The `whereSnapshot` helper supplies the Broadway root and forwards readiness +hooks. Prepare size-changing state with `onReadyToMeasure`. Use +`onReadyToSnapshot` to restore that same state after capture rehosts the view. + To re-record after an intentional UI change (see the [SnapshotKitTesting README](../../Shared/SnapshotKitTesting/README.md#recording) for the mode values): diff --git a/Where/WhereUI/SnapshotTests/BackupSettingsSectionSnapshotTests.swift b/Where/WhereUI/SnapshotTests/BackupSettingsSectionSnapshotTests.swift new file mode 100644 index 000000000..7597798aa --- /dev/null +++ b/Where/WhereUI/SnapshotTests/BackupSettingsSectionSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct BackupSettingsSectionSnapshotTests { + @Test func backupSettings() async { + await assertSnapshots(of: BackupSettingsSection.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.NoBackups_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.NoBackups_iPhone.png new file mode 100644 index 000000000..3a8b8f476 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.NoBackups_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f5ca64437f946b811fb284fc983104597e0530913d65dad9b758b7e3ddde2d8d +size 225847 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.NoBackups_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.NoBackups_iPhone_dark.png new file mode 100644 index 000000000..420af5e33 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.NoBackups_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dbd6880bb2f99feca548c1f16c53f5c8e2ff11f72f2ab6c3a901dc5c1245db10 +size 232597 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.PartialICloudFailure_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.PartialICloudFailure_iPhone.png new file mode 100644 index 000000000..780a3b2b7 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.PartialICloudFailure_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a6929286b717f0348b519fad9b04eeb2793ffde3fcc30a137dc75039ce0b4e60 +size 248468 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.PartialICloudFailure_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.PartialICloudFailure_iPhone_dark.png new file mode 100644 index 000000000..a218bb5b5 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.PartialICloudFailure_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e43d3c94ccc64d80868ded1f06ea24db9dbdc090f1acc6a1c79ff0ffbacd1067 +size 255943 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.Populated_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.Populated_iPhone.png new file mode 100644 index 000000000..7ac8dbc67 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.Populated_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36a270e09916fba13d3b66537e9480c1ae0e09240e3f8c5e64b8054545aec04a +size 254374 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.Populated_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.Populated_iPhone_dark.png new file mode 100644 index 000000000..4e824f5c6 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.Populated_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d62ffb70e0592ec61248ea405a98aed056b7272b23d125622ab6a8ee5478e31 +size 262101 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.RecordingDisabled_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.RecordingDisabled_iPhone.png new file mode 100644 index 000000000..b46500804 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.RecordingDisabled_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7280eab942ba2c001fc13e0bcaa62b12ca87fec378c2d189ce8a92493fe34d49 +size 240716 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.RecordingDisabled_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.RecordingDisabled_iPhone_dark.png new file mode 100644 index 000000000..1aa9edea3 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.RecordingDisabled_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e6d3883689389cb9838bd2846ea405681b96f3ee0f82cc8df7d133e315871e1 +size 247588 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.RevealedKey_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.RevealedKey_iPhone.png new file mode 100644 index 000000000..17409aa7d --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.RevealedKey_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3192e1f3e478519c73922f30241bcc4d9459aed26666bed40ed0de61e2fe50ce +size 256730 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.RevealedKey_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.RevealedKey_iPhone_dark.png new file mode 100644 index 000000000..1e705b716 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/BackupSettingsSectionSnapshotTests/backupSettings.RevealedKey_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c46577b6e98cef3bf601b1042df2bcebb034def8fb8c8fe649876056aed2145c +size 265332 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad.png index 4623ca29e..79dbd4404 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:794af4730078329446edfccaed1c30e22898eff8078cc935bbf1664c334128f9 -size 3111750 +oid sha256:979a3ca1184194644165e2e9dad570b083b88cf38256fb60546189fcc792c85a +size 3312838 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_accessibility.png index 84e84470f..c60fb19af 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:62596303482f5c429ab92edff070adf074c238f7b3cc480c7ba8fa88146d698f -size 1835556 +oid sha256:962bd99edbd2bbe5e00f719ccc9de59442b275471754e7a8f9e16575cc0671ea +size 2196810 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_ax5.png index aaba0aaa2..697291b85 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b5e082390be0033ea53ba8926f63e484a02f465da873d7533b2183fbe7182248 -size 9837159 +oid sha256:b9d48080c70498566a92f5e7d65b272bd1e6e4365815120e27f7615f80d1ee15 +size 10567796 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_contrast.png index 0abadf250..e81f92db4 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:605e825d4ddd4ae5be5557139c3cdf655583ec501aa151f874206ba40621711d -size 3114658 +oid sha256:19d96f8d518a527e7f2219cb11e9683d520444eacb70e2f7142c61d9ddbdc72e +size 3324954 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_dark.png index 73ca6b0e5..61bc63fb3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4d4a89a88022153a3d1ceab563f54f56548e9708811ca64668beb871578d9b24 -size 3113332 +oid sha256:f2311416c4ccd62c9d1742fe10597d27e3b7984109c0db0ffd965a947c42a94f +size 3328296 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone.png index d7aee069c..20e148da4 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:77481ddc6d388118c4cd5336a57572c50c5a4db5403f84b5476a2b51384e771b -size 1885962 +oid sha256:346fa63dae96aaffb92537dc572db66abe732bc11663fe6da315072cebc7c9cf +size 2121679 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_accessibility.png index a446ed95e..612100506 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:29d55d0cfcff30f21abfb3c65787c65bede9ca623d5a3a47680a2bee3211ea29 -size 1230742 +oid sha256:ae950490eda5d3cff0e953abb0ffcea2c41a6fff6f5e3aa8078d74c2e41e9a7c +size 1660457 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_ax5.png index 49681795e..5d01dc278 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7c9aabdc2110389c423a940ca4889b9809b984a6f78b8c5700c81c28147e4e03 -size 7386242 +oid sha256:539bd0fc7880f3f84615cac431e882633ad12f35b173e26d8bcaee243694ceac +size 8167760 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_contrast.png index cea248142..411f6b9af 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:623c56e8e0229a5b29096694cefe1bc28ce0861be46ef182a91584ed8e2f9b0c -size 1892590 +oid sha256:76f32dde3f47c87a083449d49c566db3f7ce85e2a33878f187dff084efc5212c +size 2141214 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_dark.png index 5c9b0391d..f12a201a2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a9a3dffa46bcdb86eaf251ed2ad6feafd790623a060a8ab6ff8079fb091ce69 -size 1888868 +oid sha256:f1c0e41d9914ca404c96fa44b21b40ce678b34f521c04ec1aeb300df87eeb223 +size 2139748 diff --git a/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift b/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift index 01bd851ca..6431c71f1 100644 --- a/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift +++ b/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift @@ -18,6 +18,7 @@ public final class FileInstallationRecordingContextStore: private static let logger = WhereLog.root(OnboardingViewLog.self) private enum Resolution { + case deferred(proposed: InstallationRecordingContext) case resolved(InstallationRecordingContext) case failed(any Error, proposed: InstallationRecordingContext) /// The authoritative directory was atomically retired, but deleting that retired copy @@ -30,13 +31,17 @@ public final class FileInstallationRecordingContextStore: var onboardingContext: InstallationRecordingContext { switch self { case let .resolved(context): context - case let .failed(_, proposed), let .resetCleanupRequired(_, proposed): proposed + case let .deferred(proposed), let .failed(_, proposed), let .resetCleanupRequired( + _, + proposed, + ): proposed } } func get() throws -> InstallationRecordingContext { switch self { case let .resolved(context): context + case .deferred: throw CocoaError(.fileReadNoPermission) case let .failed(error, _): throw error case let .resetCleanupRequired(error, _): throw error } @@ -244,6 +249,7 @@ public final class FileInstallationRecordingContextStore: makeUUID: { UUID() }, now: { Date() }, initialFailure: error, + defersLoading: true, ) return } @@ -256,6 +262,8 @@ public final class FileInstallationRecordingContextStore: kind: Self.kind(for: device.userInterfaceIdiom), makeUUID: { UUID() }, now: { Date() }, + initialFailure: nil, + defersLoading: true, ) } @@ -268,6 +276,7 @@ public final class FileInstallationRecordingContextStore: kind: RecordingDeviceKind, makeUUID: @escaping @MainActor () -> UUID, now: @escaping @MainActor () -> Date, + defersLoading: Bool, ) { self.init( fileURL: fileURL, @@ -277,6 +286,7 @@ public final class FileInstallationRecordingContextStore: makeUUID: makeUUID, now: now, initialFailure: nil, + defersLoading: defersLoading, ) } @@ -288,6 +298,7 @@ public final class FileInstallationRecordingContextStore: makeUUID: @escaping @MainActor () -> UUID, now: @escaping @MainActor () -> Date, initialFailure: (any Error)?, + defersLoading: Bool, ) { self.fileURL = fileURL self.fileManager = fileManager @@ -308,27 +319,40 @@ public final class FileInstallationRecordingContextStore: if let initialFailure { resolution = .failed(initialFailure, proposed: proposed) } else { - do { - try Self.finishInterruptedReset( - for: fileURL, - fileManager: fileManager, + resolution = .deferred(proposed: proposed) + if !defersLoading { loadAfterFirstUnlock() } + } + } + + /// Call only after the app's first-unlock barrier opens. Construction must + /// not inspect, recover, or clean up the protected sidecar directory. + public func prepareAfterFirstUnlock() throws { + loadAfterFirstUnlock() + _ = try resolution.get() + } + + private func loadAfterFirstUnlock() { + guard case let .deferred(proposed) = resolution else { return } + do { + try Self.finishInterruptedReset( + for: fileURL, + fileManager: fileManager, + ) + let loaded = try Self.load(from: fileURL, fileManager: fileManager) + resolution = .resolved(loaded?.context ?? proposed) + backupImportRecovery = loaded?.backupImportRecovery + onboardingImportCompletion = loaded?.onboardingImportCompletion + } catch { + let resetPendingURL = Self.resetPendingURL(for: fileURL) + if fileManager.fileExists( + atPath: resetPendingURL.path(percentEncoded: false), + ) { + resolution = .resetCleanupRequired( + WhereServices.ResetCleanupError(underlying: error), + proposed: proposed, ) - let loaded = try Self.load(from: fileURL, fileManager: fileManager) - resolution = .resolved(loaded?.context ?? proposed) - backupImportRecovery = loaded?.backupImportRecovery - onboardingImportCompletion = loaded?.onboardingImportCompletion - } catch { - let resetPendingURL = Self.resetPendingURL(for: fileURL) - if fileManager.fileExists( - atPath: resetPendingURL.path(percentEncoded: false), - ) { - resolution = .resetCleanupRequired( - WhereServices.ResetCleanupError(underlying: error), - proposed: proposed, - ) - } else { - resolution = .failed(error, proposed: proposed) - } + } else { + resolution = .failed(error, proposed: proposed) } } } @@ -417,6 +441,8 @@ public final class FileInstallationRecordingContextStore: case let .resetCleanupRequired(_, pending): proposed = pending wasAlreadyCommitted = true + case .deferred: + throw CocoaError(.fileReadNoPermission) case .resolved, .failed: proposed = Self.proposedContext( systemName: systemName, diff --git a/Where/WhereUI/Sources/Launch/PrepareProtectedDataStep.swift b/Where/WhereUI/Sources/Launch/PrepareProtectedDataStep.swift new file mode 100644 index 000000000..fe2f27e2c --- /dev/null +++ b/Where/WhereUI/Sources/Launch/PrepareProtectedDataStep.swift @@ -0,0 +1,13 @@ +import LifecycleKit + +/// App-owned first-unlock barrier shared by headless and UI-driven launches. +/// Like onboarding, waiting on the user is not a measured work budget. +struct PrepareProtectedDataStep: LifecycleStep { + let prepare: @MainActor () async throws -> Void + let id = LaunchStepID.protectedData + + func run(_: Void, _: LifecycleStepContext) async throws { + try await prepare() + try Task.checkCancellation() + } +} diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index f227058e5..d5ea6e062 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -9,6 +9,8 @@ import WhereCore /// untracked step); an enum makes each ID a compile-checked symbol and gives /// the launch/reset parity tests a single source of truth. public enum LaunchStepID: String, Sendable { + /// Wait for first unlock before loading the installation sidecar or opening a store. + case protectedData = "protected-data" /// Consume an optional one-shot demo request before onboarding can open a real store. case activateDemo = "activate-demo" /// First-run onboarding gate, after optional demo activation: until the user @@ -117,6 +119,7 @@ public enum WhereLaunch { public static func makeLauncher( model: WhereModel, reason: LifecycleReason, + prepareProtectedData: @escaping @MainActor () async throws -> Void = {}, onServicesReady: @escaping @MainActor (WhereServices) async -> Void = { _ in }, ) -> LifecycleRunner { logger { .runnerCreated(reason: String(describing: reason)) } @@ -126,7 +129,11 @@ public enum WhereLaunch { model.prepareLocation() ForegroundNotificationPresenter.install() }, - plan: plan(for: model, onServicesReady: onServicesReady), + plan: plan( + for: model, + prepareProtectedData: prepareProtectedData, + onServicesReady: onServicesReady, + ), ) // Mirror detached-step failures into WhereLog: the runner only // records them on its observable `detachedFailures`, which nothing @@ -159,9 +166,11 @@ public enum WhereLaunch { /// is a human's, not the app's. public static func plan( for model: WhereModel, + prepareProtectedData: @escaping @MainActor () async throws -> Void = {}, onServicesReady: @escaping @MainActor (WhereServices) async -> Void = { _ in }, ) -> LaunchPlan { - LaunchPlan(ActivateLaunchDemoStep(model: model).measured()) + LaunchPlan(PrepareProtectedDataStep(prepare: prepareProtectedData)) + .then(ActivateLaunchDemoStep(model: model).measured()) .gate(OnboardingGate(model: model)) .then(ResolveScopeStep(model: model).measured()) .then(StartSessionStep(model: model, onServicesReady: onServicesReady).measured()) @@ -257,6 +266,9 @@ public final class WhereBootstrap: WhereScopeAssembling { private let installationContextStore: any InstallationRecordingContextStoring private let storeStorage: SwiftDataStore.Storage private let locationOutbox: any LocationOutbox + private let backupRecoveryKeys: BackupRecoveryKeyProvider? + private let automaticBackupStorage: AutomaticBackupStorage? + private let automaticBackupScheduler: any AutomaticBackupTaskScheduling private var locationSource: CoreLocationSource? private var preparedStore: SwiftDataStore? @@ -264,10 +276,17 @@ public final class WhereBootstrap: WhereScopeAssembling { installationContextStore: any InstallationRecordingContextStoring, storeStorage: SwiftDataStore.Storage, locationOutbox: any LocationOutbox, + backupRecoveryKeys: BackupRecoveryKeyProvider? = nil, + automaticBackupStorage: AutomaticBackupStorage? = nil, + automaticBackupScheduler: any AutomaticBackupTaskScheduling = + NoopAutomaticBackupTaskScheduler(), ) { self.installationContextStore = installationContextStore self.storeStorage = storeStorage self.locationOutbox = locationOutbox + self.backupRecoveryKeys = backupRecoveryKeys + self.automaticBackupStorage = automaticBackupStorage + self.automaticBackupScheduler = automaticBackupScheduler } /// Install the `CLLocationManager` + delegate right away, without touching @@ -317,6 +336,9 @@ public final class WhereBootstrap: WhereScopeAssembling { widgetRefresher: WidgetCenterTimelineRefresher(), locationOutbox: locationOutbox, importRecoveryPersistence: installationContextStore, + backupRecoveryKeys: backupRecoveryKeys, + automaticBackupStorage: automaticBackupStorage, + automaticBackupScheduler: automaticBackupScheduler, ) Self.logger { .servicesAssembled } return services diff --git a/Where/WhereUI/Sources/Logging/WhereSessionLog.swift b/Where/WhereUI/Sources/Logging/WhereSessionLog.swift index 54038ffe2..37e75c38d 100644 --- a/Where/WhereUI/Sources/Logging/WhereSessionLog.swift +++ b/Where/WhereUI/Sources/Logging/WhereSessionLog.swift @@ -13,6 +13,7 @@ enum WhereSessionLog: LogEvent { case trackingEnabled = "tracking-enabled" case stoppedBackgroundTracking = "stopped-background-tracking" case recordingReconcileFailed = "recording-reconcile-failed" + case automaticBackupFailed = "automatic-backup-failed" case remindersUnauthorized = "reminders-unauthorized" case summaryUnauthorized = "summary-unauthorized" case issueAlertsUnauthorized = "issue-alerts-unauthorized" @@ -41,6 +42,7 @@ enum WhereSessionLog: LogEvent { case trackingEnabled case stoppedBackgroundTracking case recordingReconcileFailed(description: String) + case automaticBackupFailed(description: String) case remindersUnauthorized case summaryUnauthorized case issueAlertsUnauthorized @@ -55,6 +57,8 @@ enum WhereSessionLog: LogEvent { .summaryUnauthorized, .issueAlertsUnauthorized, .regionStylesLoadFailed, .recordingReconcileFailed: .warning + case .automaticBackupFailed: + .error case .backgroundTrackingStarted, .backgroundTrackingStopped, .permissionGranted, .trackingEnabled, .stoppedBackgroundTracking, .erasedSession: .info @@ -79,6 +83,8 @@ enum WhereSessionLog: LogEvent { "Stopped background tracking" case let .recordingReconcileFailed(description): "Failed to reconcile device recording policy: \(description)" + case let .automaticBackupFailed(description): + "Automatic backup failed: \(description)" case .remindersUnauthorized: "Logging reminders enabled but notifications not authorized" case .summaryUnauthorized: @@ -106,6 +112,7 @@ enum WhereSessionLog: LogEvent { case .trackingEnabled: .trackingEnabled case .stoppedBackgroundTracking: .stoppedBackgroundTracking case .recordingReconcileFailed: .recordingReconcileFailed + case .automaticBackupFailed: .automaticBackupFailed case .remindersUnauthorized: .remindersUnauthorized case .summaryUnauthorized: .summaryUnauthorized case .issueAlertsUnauthorized: .issueAlertsUnauthorized diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index bbc7634f0..3aede3123 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -625,6 +625,7 @@ 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 { + await activeScope?.services.automaticBackups?.shutDown() 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 0fa23cc60..3f43d476e 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -51,6 +51,13 @@ public final class WhereSession { return configuration.device.status == .recording } + /// Whether this installation's resolved local policy enables automatic + /// recording. Automatic backups use policy, not transient GPS attachment, + /// as their availability gate. + public var isAutomaticRecordingEnabled: Bool { + recordingEnabled + } + public var isCurrentDeviceRemoved: Bool { if case .removed = recordingRuntimeState { true } else { false } } @@ -236,6 +243,7 @@ public final class WhereSession { await applyReminderConfiguration() await applySummaryConfiguration() await applyIssueAlertConfiguration() + await runAutomaticBackupIfDue() // Republish the widget snapshot from whatever is already on disk so a // cold launch with no writes this session doesn't leave the widget // blank or showing the previous day's "today". @@ -254,6 +262,7 @@ public final class WhereSession { await applyReminderConfiguration() await applySummaryConfiguration() await applyIssueAlertConfiguration() + await runAutomaticBackupIfDue() // The calendar day may have rolled over while backgrounded; // recompute so the widget's "today" reflects the current day rather // than stale foreground state. @@ -355,6 +364,12 @@ public final class WhereSession { for await update in updates { guard let self else { break } applyRecordingRuntimeUpdate(update) + await services.automaticBackups?.reconcileSchedule(configuration: .init( + isEnabled: preferences.automaticBackupsEnabled, + isRecordingEnabled: isAutomaticRecordingEnabled, + interval: preferences.automaticBackupInterval, + lastSuccessfulBackupAt: preferences.lastAutomaticBackupAt, + )) } } } @@ -503,6 +518,35 @@ public final class WhereSession { } else if configuration.localAutomaticRecordingEnabled == false { Self.logger { .stoppedBackgroundTracking } } + await runAutomaticBackupIfDue() + } + + /// Runs the first/due automatic backup and advances success metadata only + /// after the encrypted container is durably stored. + @discardableResult + public func runAutomaticBackupIfDue() async -> AutomaticBackupRunResult? { + guard let automaticBackups = services.automaticBackups else { return nil } + let generation = preferences.resetGeneration + do { + let result = try await automaticBackups.runIfDue( + cancellation: .cancelExecution, + configuration: .init( + isEnabled: preferences.automaticBackupsEnabled, + isRecordingEnabled: isAutomaticRecordingEnabled, + interval: preferences.automaticBackupInterval, + lastSuccessfulBackupAt: preferences.lastAutomaticBackupAt, + ), + ) + if case let .completed(exportedAt) = result { + preferences.recordAutomaticBackupSuccess(at: exportedAt, generation: generation) + } + return result + } catch { + Self.logger(attachments: [.error(error, name: "automatic-backup-error")]) { + .automaticBackupFailed(description: error.localizedDescription) + } + return nil + } } public func renameRecordingDevice( diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingFlowModel.swift b/Where/WhereUI/Sources/Onboarding/OnboardingFlowModel.swift index 590907054..6453c8062 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingFlowModel.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingFlowModel.swift @@ -36,6 +36,8 @@ final class OnboardingFlowModel { var intro = OnboardingIntroState() var showImporter = false var showRestoreStrategyDialog = false + var showRecoveryKeyPrompt = false + var enteredRecoveryKey = "" private static let demoBuildDisplayTime = Duration.seconds(2) private static let logger = WhereLog.session(OnboardingViewLog.self) @@ -225,9 +227,21 @@ final class OnboardingFlowModel { using model: WhereModel, ) async -> Bool { do { + let recoveryKey: BackupRecoveryKey? = if readyImport.url.pathExtension.lowercased() + == "wherebackup" + { + try await scope.services.automaticBackups?.restoreRecoveryKey( + archiveURL: readyImport.url, + explicitBase64: enteredRecoveryKey + .trimmingCharacters(in: .whitespacesAndNewlines), + ) + } else { + nil + } let summary = try await scope.services.backup.importBackup( from: readyImport.url, strategy: readyImport.strategy, + recoveryKey: recoveryKey, ) { _ in } restoreSelection.markCommitted(summary) model.completeOnboarding() @@ -241,6 +255,15 @@ final class OnboardingFlowModel { return false } return true + } catch is BackupCoordinator.RecoveryKeyRequiredError { + await requestRecoveryKey(using: model) + return false + } catch BackupService.EncryptedBackupError.recoveryKeyMismatch { + await requestRecoveryKey(using: model) + return false + } catch BackupRecoveryKeyProvider.ProviderError.malformedKey { + await requestRecoveryKey(using: model) + return false } catch let error as BackupCoordinator.CommittedImportCleanupError { restoreSelection.markCommitted(error.summary) model.completeOnboarding() @@ -288,6 +311,15 @@ final class OnboardingFlowModel { } } + private func requestRecoveryKey(using model: WhereModel) async { + await model.endSession() + intro.activity = .browsing + phase = .intro + isFinishing = false + enteredRecoveryKey = "" + showRecoveryKeyPrompt = true + } + private func configureRecording(in scope: WhereScope) async throws { let authorization = await scope.services.ingestor.authorizationStatus() try await scope.services.recording.registerForOnboarding( diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index 2bb2856b8..fbf38ead3 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -140,7 +140,7 @@ public struct OnboardingView: View { } .fileImporter( isPresented: $flow.showImporter, - allowedContentTypes: [.zip], + allowedContentTypes: [.zip, .whereBackup], onCompletion: flow.handleRestoreSelection, ) .confirmationDialog( @@ -161,6 +161,25 @@ public struct OnboardingView: View { } message: { _ in Text(String(localized: .settingsBackupImportStrategyMessage)) } + .alert( + String(localized: "onboarding.restore.recoveryKey.title", bundle: .module), + isPresented: $flow.showRecoveryKeyPrompt, + ) { + TextField( + String(localized: "onboarding.restore.recoveryKey.placeholder", bundle: .module), + text: $flow.enteredRecoveryKey, + ) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button(String(localized: "onboarding.restore.recoveryKey.continue", bundle: .module)) { + flow.finish(using: model) + } + Button(String(localized: .settingsDataCancel), role: .cancel) { + flow.discardPendingRestore() + } + } message: { + Text(String(localized: "onboarding.restore.recoveryKey.message", bundle: .module)) + } .alert( flow.failureTitle, isPresented: $flow.intro.isShowingFailure, diff --git a/Where/WhereUI/Sources/Onboarding/WhereBackupContentType.swift b/Where/WhereUI/Sources/Onboarding/WhereBackupContentType.swift new file mode 100644 index 000000000..647b68dc0 --- /dev/null +++ b/Where/WhereUI/Sources/Onboarding/WhereBackupContentType.swift @@ -0,0 +1,10 @@ +import UniformTypeIdentifiers + +extension UTType { + /// Encrypted Where backup container. The payload is a ZIP, but its custom + /// extension prevents it from being mistaken for a plaintext manual export. + static let whereBackup = UTType( + exportedAs: "com.stuff.where.encrypted-backup", + conformingTo: .zip, + ) +} diff --git a/Where/WhereUI/Sources/Preview/WhereSnapshot.swift b/Where/WhereUI/Sources/Preview/WhereSnapshot.swift index a01d37b9f..522360289 100644 --- a/Where/WhereUI/Sources/Preview/WhereSnapshot.swift +++ b/Where/WhereUI/Sources/Preview/WhereSnapshot.swift @@ -8,15 +8,16 @@ /// wrapper. Use this instead of `SnapshotCase(...)` directly when authoring /// WhereUI ``SnapshotProviding`` conformances. /// - /// `onReadyToSnapshot` passes through to ``SnapshotCase``: the capture - /// pipeline runs it after the content settles and re-settles its effects — - /// the seam for a deterministic completion signal (e.g. awaiting a launch - /// runner's drive) that pixel stability alone can't provide. + /// Readiness hooks pass through to ``SnapshotCase``. `onReadyToMeasure` + /// prepares size-changing state before measurement. `onReadyToSnapshot` + /// runs after content settles; capture then settles its effects. + /// Use these hooks for readiness that pixel stability alone cannot prove. @MainActor public func whereSnapshot( name: String, configurations: [SnapshotConfiguration], measurementReadiness: SnapshotMeasurementReadiness = .sameAsCapture, + onReadyToMeasure: (@MainActor () async -> Void)? = nil, settle: SnapshotSettle = .settled, onReadyToSnapshot: (@MainActor () async -> Void)? = nil, @ViewBuilder content: @escaping @MainActor () -> some View, @@ -25,6 +26,7 @@ name: name, configurations: configurations, measurementReadiness: measurementReadiness, + onReadyToMeasure: onReadyToMeasure, settle: settle, onReadyToSnapshot: onReadyToSnapshot, ) { diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index d0f96e159..baeaceafc 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -4376,6 +4376,50 @@ } } }, + "onboarding.restore.recoveryKey.continue" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlock Backup" + } + } + } + }, + "onboarding.restore.recoveryKey.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The synchronized key is missing or does not match. Enter the Base64 recovery key copied from the Data page." + } + } + } + }, + "onboarding.restore.recoveryKey.placeholder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recovery key" + } + } + } + }, + "onboarding.restore.recoveryKey.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recovery Key Required" + } + } + } + }, "onboarding.restoreBackup" : { "extractionState" : "manual", "localizations" : { @@ -6135,6 +6179,63 @@ } } }, + "settings.backup.automatic.enabled" : { + "comment" : "Toggle title for automatic encrypted backups.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatic Backups" + } + } + } + }, + "settings.backup.automatic.footer" : { + "comment" : "Explains why automatic backups matter and how they are protected.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your residency history is critically important. Encrypted backups can restore it if app data is damaged or lost. Where keeps the newest three." + } + } + } + }, + "settings.backup.automatic.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatic Backups" + } + } + } + }, + "settings.backup.automatic.interval" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Backup Interval" + } + } + } + }, + "settings.backup.automatic.unavailable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Available when automatic recording is on" + } + } + } + }, "settings.backup.cleanupRequired" : { "comment" : "Settings footer while a committed backup import still needs its post-commit cleanup retried.", "extractionState" : "manual", @@ -6268,6 +6369,127 @@ } } }, + "settings.backup.interval.daily" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daily" + } + } + } + }, + "settings.backup.interval.monthly" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monthly" + } + } + } + }, + "settings.backup.interval.weekly" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weekly" + } + } + } + }, + "settings.backup.list.empty" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No automatic backups yet" + } + } + } + }, + "settings.backup.list.encrypted" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Encrypted" + } + } + } + }, + "settings.backup.list.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatic Backups" + } + } + } + }, + "settings.backup.list.icloudUnavailable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud Drive is unavailable. Showing backups stored on this device." + } + } + } + }, + "settings.backup.list.retry" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retry" + } + } + } + }, + "settings.backup.list.sizeUnavailable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Size unavailable" + } + } + } + }, + "settings.backup.location.device" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On This Device" + } + } + } + }, + "settings.backup.location.icloud" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud Drive" + } + } + } + }, "settings.backup.merge" : { "extractionState" : "manual", "localizations" : { @@ -6279,6 +6501,72 @@ } } }, + "settings.backup.recovery.copy" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy Recovery Key" + } + } + } + }, + "settings.backup.recovery.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recovery keys sync through iCloud Keychain. This key unlocks backups made by this device. Keep a safe copy from each recording device." + } + } + } + }, + "settings.backup.recovery.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recovery Key" + } + } + } + }, + "settings.backup.recovery.hide" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hide Recovery Key" + } + } + } + }, + "settings.backup.recovery.show" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Recovery Key" + } + } + } + }, + "settings.backup.recovery.value" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Backup recovery key" + } + } + } + }, "settings.backup.replace" : { "extractionState" : "manual", "localizations" : { @@ -8372,6 +8660,17 @@ } } }, + "settings.keywords.automaticBackups" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "automatic, backup, encrypted, daily, weekly, monthly, iCloud" + } + } + } + }, "settings.keywords.dataResolution" : { "extractionState" : "manual", "localizations" : { @@ -8516,6 +8815,17 @@ } } }, + "settings.keywords.recoveryKey" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "recovery, key, encryption, show, hide, copy" + } + } + } + }, "settings.keywords.regions" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Sources/Settings/BackupModel.swift b/Where/WhereUI/Sources/Settings/BackupModel.swift index da2d3d090..6bbb01fbd 100644 --- a/Where/WhereUI/Sources/Settings/BackupModel.swift +++ b/Where/WhereUI/Sources/Settings/BackupModel.swift @@ -24,6 +24,19 @@ public final class BackupModel { /// a determinate progress bar. Reset to `0` whenever neither is running. public private(set) var backupProgress: Double = 0 + public enum CatalogState: Equatable { + case idle + case loading + case loaded(AutomaticBackupCatalog) + case failed(String) + } + + public private(set) var catalogState: CatalogState = .idle + public private(set) var revealedRecoveryKey: String? + + public private(set) var automaticBackupsEnabled: Bool + public private(set) var automaticBackupInterval: AutomaticBackupInterval + private var presentedError: String? /// Last backup failure, surfaced as an alert. @@ -43,10 +56,31 @@ public final class BackupModel { } private let services: WhereServices + private let preferences: WherePreferences? + @ObservationIgnored private var changesTask: Task? + private struct CatalogRequest { + let id: UUID + let task: Task + } + + @ObservationIgnored private var catalogRequest: CatalogRequest? + @ObservationIgnored private var appearanceGeneration = UUID() + @ObservationIgnored private var recoveryKeyRequest = UUID() + #if DEBUG + private var freezesPreviewState = false + #endif private static let logger = WhereLog.session(BackupModelLog.self) - public init(services: WhereServices) { + public init(services: WhereServices, preferences: WherePreferences? = nil) { self.services = services + self.preferences = preferences + automaticBackupsEnabled = preferences?.automaticBackupsEnabled ?? true + automaticBackupInterval = preferences?.automaticBackupInterval ?? .weekly + } + + deinit { + changesTask?.cancel() + catalogRequest?.task.cancel() } /// Build a backup `.zip` of the entire database and return its URL for the @@ -98,4 +132,135 @@ public final class BackupModel { public func presentBackupError(_ error: any Error) { presentedError = error.localizedDescription } + + public func setAutomaticBackupsEnabled(_ isEnabled: Bool) { + guard automaticBackupsEnabled != isEnabled else { return } + automaticBackupsEnabled = isEnabled + preferences?.automaticBackupsEnabled = isEnabled + } + + public func setAutomaticBackupInterval(_ interval: AutomaticBackupInterval) { + guard automaticBackupInterval != interval else { return } + automaticBackupInterval = interval + preferences?.automaticBackupInterval = interval + } + + public func activate(recordingEnabled: Bool) async { + #if DEBUG + guard !freezesPreviewState else { return } + #endif + let generation = appearanceGeneration + if changesTask == nil, let automaticBackups = services.automaticBackups { + changesTask = Task { @MainActor [weak self] in + for await _ in await automaticBackups.changes() { + await self?.refreshCatalog() + } + } + } + await runIfDue(recordingEnabled: recordingEnabled) + guard appearanceGeneration == generation, !Task.isCancelled else { return } + await refreshCatalog() + } + + public func runIfDue(recordingEnabled: Bool) async { + guard let automaticBackups = services.automaticBackups, let preferences else { return } + let generation = preferences.resetGeneration + do { + let result = try await automaticBackups.runIfDue( + cancellation: .finishExecution, + configuration: .init( + isEnabled: preferences.automaticBackupsEnabled, + isRecordingEnabled: recordingEnabled, + interval: preferences.automaticBackupInterval, + lastSuccessfulBackupAt: preferences.lastAutomaticBackupAt, + ), + ) + if case let .completed(exportedAt) = result { + preferences.recordAutomaticBackupSuccess(at: exportedAt, generation: generation) + } + } catch is CancellationError { + // Reset, disable, or background expiration may cancel the owned run. + } catch { + guard !Task.isCancelled else { return } + presentBackupError(error) + } + } + + public func refreshCatalog() async { + guard !Task.isCancelled else { return } + guard let automaticBackups = services.automaticBackups else { + catalogState = .loaded(AutomaticBackupCatalog(files: [], isICloudUnavailable: false)) + return + } + catalogState = .loading + catalogRequest?.task.cancel() + let request = CatalogRequest( + id: UUID(), + task: Task { try await automaticBackups.catalog() }, + ) + catalogRequest = request + defer { + if catalogRequest?.id == request.id { catalogRequest = nil } + } + do { + let catalog = try await withTaskCancellationHandler { + try await request.task.value + } onCancel: { request.task.cancel() } + guard catalogRequest?.id == request.id else { return } + try Task.checkCancellation() + catalogState = .loaded(catalog) + } catch is CancellationError { + if catalogRequest?.id == request.id { catalogState = .idle } + } catch { + guard catalogRequest?.id == request.id else { return } + if Task.isCancelled { + catalogState = .idle + return + } + catalogState = .failed(error.localizedDescription) + } + } + + /// Stop view-owned reads and observations, never the app-owned export. + public func deactivate() { + appearanceGeneration = UUID() + hideRecoveryKey() + changesTask?.cancel() + changesTask = nil + if let catalogRequest { + catalogRequest.task.cancel() + self.catalogRequest = nil + catalogState = .idle + } + } + + public func revealRecoveryKey() async { + guard let automaticBackups = services.automaticBackups else { return } + let request = UUID() + recoveryKeyRequest = request + do { + let key = try await automaticBackups.recoveryKey() + guard recoveryKeyRequest == request, !Task.isCancelled else { return } + revealedRecoveryKey = key + } catch { + guard recoveryKeyRequest == request, !Task.isCancelled else { return } + presentBackupError(error) + } + } + + public func hideRecoveryKey() { + recoveryKeyRequest = UUID() + revealedRecoveryKey = nil + } + + #if DEBUG + func configurePreview( + catalogState: CatalogState, + recoveryKey: String? = nil, + ) { + freezesPreviewState = true + self.catalogState = catalogState + revealedRecoveryKey = recoveryKey + } + #endif } diff --git a/Where/WhereUI/Sources/Settings/BackupSettingsContent.swift b/Where/WhereUI/Sources/Settings/BackupSettingsContent.swift new file mode 100644 index 000000000..43401cf5c --- /dev/null +++ b/Where/WhereUI/Sources/Settings/BackupSettingsContent.swift @@ -0,0 +1,273 @@ +import PeriscopeCore +import SFSafeSymbols +import SwiftUI +import UIKit +import UniformTypeIdentifiers +import WhereCore + +/// The shared backup sections and controls, independent of screen activation. +/// The parent owns observation and secret-hiding lifecycle; snapshots pin these sections. +struct BackupSettingsContent: View { + let backup: BackupModel + let recordingEnabled: Bool + @State private var presentedShareItem: BackupShareSheet.Item? + + var body: some View { + @Bindable var backup = backup + Group { + manualExportSection + automaticConfigurationSection + recoveryKeySection + automaticBackupsSection + } + .sheet(item: $presentedShareItem) { item in + BackupShareSheet(item: item) + } + .alert( + localized("settings.backup.errorTitle"), + isPresented: $backup.isShowingBackupError, + presenting: backup.backupError, + ) { _ in + Button(String(localized: .commonOk), role: .cancel) {} + } message: { message in + Text(message) + } + } + + private var manualExportSection: some View { + Section { + Button { runExport() } label: { + if backup.backupState == .exporting { + backupProgressLabel( + String(localized: .settingsBackupExporting), + systemSymbol: .squareAndArrowUp, + ) + } else { + Label( + String(localized: .settingsBackupExport), + systemSymbol: .squareAndArrowUp, + ) + } + } + .disabled(backup.backupState != .idle) + .settingsRow(DataSettingsView.Item.exportBackup) + } header: { + Text(String(localized: .settingsBackupHeader)) + } footer: { + Text(String(localized: .settingsBackupFooter)) + } + .debugLogInspectable(WhereLog.session(BackupModelLog.self)) + } + + private var automaticConfigurationSection: some View { + Section { + Toggle( + localized("settings.backup.automatic.enabled"), + isOn: Binding( + get: { backup.automaticBackupsEnabled }, + set: { value in + backup.setAutomaticBackupsEnabled(value) + Task { await backup.runIfDue(recordingEnabled: recordingEnabled) } + }, + ), + ) + .disabled(!recordingEnabled) + .settingsRow(DataSettingsView.Item.automaticBackups) + + Picker( + localized("settings.backup.automatic.interval"), + selection: Binding( + get: { backup.automaticBackupInterval }, + set: { + backup.setAutomaticBackupInterval($0) + Task { await backup.runIfDue(recordingEnabled: recordingEnabled) } + }, + ), + ) { + ForEach(AutomaticBackupInterval.allCases, id: \.self) { interval in + Text(title(for: interval)).tag(interval) + } + } + .disabled(!recordingEnabled || !backup.automaticBackupsEnabled) + .settingsRow(DataSettingsView.Item.backupInterval) + + if !recordingEnabled { + Label( + localized("settings.backup.automatic.unavailable"), + systemSymbol: .locationSlash, + ) + .foregroundStyle(.secondary) + } + } header: { + Text(localized("settings.backup.automatic.header")) + } footer: { + Text(localized("settings.backup.automatic.footer")) + } + } + + private var recoveryKeySection: some View { + Section { + if let key = backup.revealedRecoveryKey { + Text(key) + .font(.system(.body, design: .monospaced)) + .accessibilityLabel(localized("settings.backup.recovery.value")) + + Button { + copyRecoveryKey(key) + } label: { + Label( + localized("settings.backup.recovery.copy"), + systemSymbol: .docOnDoc, + ) + } + .settingsRow(DataSettingsView.Item.copyRecoveryKey) + + Button { + backup.hideRecoveryKey() + } label: { + Label( + localized("settings.backup.recovery.hide"), + systemSymbol: .eyeSlash, + ) + } + } else { + Button { + Task { await backup.revealRecoveryKey() } + } label: { + Label( + localized("settings.backup.recovery.show"), + systemSymbol: .eye, + ) + } + .settingsRow(DataSettingsView.Item.recoveryKey) + } + } header: { + Text(localized("settings.backup.recovery.header")) + } footer: { + Text(localized("settings.backup.recovery.footer")) + } + } + + private var automaticBackupsSection: some View { + Section { + switch backup.catalogState { + case .idle, .loading: + HStack { + Spacer() + ProgressView() + Spacer() + } + case let .loaded(catalog) where catalog.files.isEmpty: + Text(localized("settings.backup.list.empty")) + .foregroundStyle(.secondary) + if catalog.isICloudUnavailable { + iCloudUnavailableWarning + } + case let .loaded(catalog): + ForEach(catalog.files) { file in + backupRow(file) + } + if catalog.isICloudUnavailable { + iCloudUnavailableWarning + } + case let .failed(message): + VStack(alignment: .leading, spacing: 8) { + Text(message).foregroundStyle(.secondary) + Button(localized("settings.backup.list.retry")) { + Task { await backup.refreshCatalog() } + } + } + } + } header: { + Text(localized("settings.backup.list.header")) + } + } + + private var iCloudUnavailableWarning: some View { + Label( + localized("settings.backup.list.icloudUnavailable"), + systemSymbol: .exclamationmarkIcloud, + ) + .foregroundStyle(.secondary) + } + + private func backupRow(_ file: AutomaticBackupFile) -> some View { + LabeledContent { + VStack(alignment: .trailing, spacing: 2) { + Text(size(for: file)) + Text(location(for: file.storageLocation)) + .font(.caption) + .foregroundStyle(.secondary) + } + } label: { + Label { + Text(file.exportedAt.formatted(date: .abbreviated, time: .shortened)) + } icon: { + Image(systemSymbol: .lockFill) + .accessibilityLabel(localized("settings.backup.list.encrypted")) + } + } + } + + private func size(for file: AutomaticBackupFile) -> String { + guard let byteCount = file.byteCount else { + return localized("settings.backup.list.sizeUnavailable") + } + return byteCount.formatted(ByteCountFormatStyle(style: .file)) + } + + private func location(for location: AutomaticBackupFile.StorageLocation) -> String { + switch location { + case .iCloudDrive: localized("settings.backup.location.icloud") + case .appDocuments: localized("settings.backup.location.device") + } + } + + private func title(for interval: AutomaticBackupInterval) -> String { + switch interval { + case .daily: localized("settings.backup.interval.daily") + case .weekly: localized("settings.backup.interval.weekly") + case .monthly: localized("settings.backup.interval.monthly") + } + } + + private func localized(_ key: String.LocalizationValue) -> String { + String(localized: key, bundle: .module) + } + + private func copyRecoveryKey(_ key: String) { + UIPasteboard.general.setItems( + [[UTType.plainText.identifier: key]], + options: [ + .localOnly: true, + .expirationDate: Date().addingTimeInterval(5 * 60), + ], + ) + } + + /// Determinate progress for an in-flight export, driven by + /// `backup.backupProgress` as the backup coordinator makes progress. + private func backupProgressLabel(_ title: String, systemSymbol: SFSymbol) -> some View { + VStack(alignment: .leading, spacing: 4) { + Label(title, systemSymbol: systemSymbol) + ProgressView(value: backup.backupProgress) + } + } + + private func runExport() { + Task { + if let url = await backup.exportBackup() { + presentedShareItem = BackupShareSheet.Item(url: url) + } + } + } +} + +#if DEBUG + #Preview { + Form { + BackupSettingsContent(backup: PreviewSupport.backupModel(), recordingEnabled: true) + } + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Settings/BackupSettingsSection.swift b/Where/WhereUI/Sources/Settings/BackupSettingsSection.swift index 978da799f..4936a7db5 100644 --- a/Where/WhereUI/Sources/Settings/BackupSettingsSection.swift +++ b/Where/WhereUI/Sources/Settings/BackupSettingsSection.swift @@ -1,92 +1,142 @@ import PeriscopeCore -import SFSafeSymbols +#if DEBUG + import SnapshotKit +#endif import SwiftUI import WhereCore -/// The whole-database backup section embedded in ``DataSettingsView``. -/// Imports deliberately live only in onboarding, where the app can recover a -/// committed archive before exposing a running session. +/// Owns the Data page's automatic-backup observations and recovery-key lifetime. struct BackupSettingsSection: View { let backup: BackupModel - - /// Backup export: the ready-to-share archive built up-front, presented as - /// soon as the background export finishes. - @State private var presentedShareItem: BackupShareSheet.Item? + let recordingEnabled: Bool + @Environment(\.scenePhase) private var scenePhase var body: some View { - @Bindable var backup = backup - backupSection - .sheet(item: $presentedShareItem) { item in - BackupShareSheet(item: item) + BackupSettingsContent(backup: backup, recordingEnabled: recordingEnabled) + .task(id: recordingEnabled) { + await backup.activate(recordingEnabled: recordingEnabled) } - .alert( - String(localized: .settingsBackupErrorTitle), - isPresented: $backup.isShowingBackupError, - presenting: backup.backupError, - ) { _ in - Button(String(localized: .commonOk), role: .cancel) {} - } message: { message in - Text(message) + .onChange(of: scenePhase) { _, phase in + switch phase { + case .active: + Task { await backup.activate(recordingEnabled: recordingEnabled) } + case .inactive, .background: + backup.hideRecoveryKey() + @unknown default: + backup.hideRecoveryKey() + } } + .onDisappear { backup.deactivate() } } +} - private var backupSection: some View { - Section { - // The archive is built up-front on a background task (with an - // in-app "Exporting…" bar), then handed to the system activity sheet - // as a ready file — so it opens instantly instead of sitting in the - // system's blocking "Preparing…" state. - Button { - runExport() - } label: { - if backup.backupState == .exporting { - backupProgressLabel( - String(localized: .settingsBackupExporting), - systemSymbol: .squareAndArrowUp, +#if DEBUG + extension BackupSettingsSection: SnapshotProviding { + static var snapshots: [SnapshotCase] { + [ + whereSnapshot( + name: "RecordingDisabled", + configurations: .fullContentPhoneLightDark, + ) { + snapshotForm(recordingEnabled: false) + }, + whereSnapshot( + name: "NoBackups", + configurations: .fullContentPhoneLightDark, + ) { + snapshotForm(recordingEnabled: true) + }, + whereSnapshot( + name: "Populated", + configurations: .fullContentPhoneLightDark, + ) { + snapshotForm( + recordingEnabled: true, + catalog: AutomaticBackupCatalog( + files: snapshotFiles, + isICloudUnavailable: false, + ), ) - } else { - Label( - String(localized: .settingsBackupExport), - systemSymbol: .squareAndArrowUp, + }, + whereSnapshot( + name: "PartialICloudFailure", + configurations: .fullContentPhoneLightDark, + ) { + snapshotForm( + recordingEnabled: true, + catalog: AutomaticBackupCatalog( + files: [], + isICloudUnavailable: true, + ), ) - } - } - .disabled(backup.backupState != .idle) - .settingsRow(DataSettingsView.Item.exportBackup) - } header: { - Text(String(localized: .settingsBackupHeader)) - } footer: { - Text(String(localized: .settingsBackupFooter)) + }, + whereSnapshot( + name: "RevealedKey", + configurations: .fullContentPhoneLightDark, + ) { + snapshotForm( + recordingEnabled: true, + recoveryKey: "VGhpcy1pcy1hLXNhbXBsZS1yZWNvdmVyeS1rZXku", + ) + }, + ] } - // Log View Mode: reveal an inspect badge for backup export - // events on this section. A no-op in release. - .debugLogInspectable(WhereLog.session(BackupModelLog.self)) - } - /// Determinate progress for an in-flight export, driven by - /// `backup.backupProgress` as the backup coordinator makes progress. - private func backupProgressLabel(_ title: String, systemSymbol: SFSymbol) -> some View { - VStack(alignment: .leading, spacing: 4) { - Label(title, systemSymbol: systemSymbol) - ProgressView(value: backup.backupProgress) + private static var snapshotFiles: [AutomaticBackupFile] { + [ + AutomaticBackupFile( + url: URL(fileURLWithPath: "/backup/newest.wherebackup"), + exportedAt: PreviewSupport.referenceNow, + byteCount: 2_450_000, + storageLocation: .iCloudDrive, + protection: .aesGCM256, + ), + AutomaticBackupFile( + url: URL(fileURLWithPath: "/backup/older.wherebackup"), + exportedAt: PreviewSupport.referenceNow.addingTimeInterval(-7 * 24 * 60 * 60), + byteCount: nil, + storageLocation: .appDocuments, + protection: .aesGCM256, + ), + ] + } + + private static func snapshotForm( + recordingEnabled: Bool, + catalog: AutomaticBackupCatalog = AutomaticBackupCatalog( + files: [], + isICloudUnavailable: false, + ), + recoveryKey: String? = nil, + ) -> some View { + let model = PreviewSupport.backupModel() + model.configurePreview( + catalogState: .loaded(catalog), + recoveryKey: recoveryKey, + ) + return snapshotForm(model: model, recordingEnabled: recordingEnabled) } - } - /// Build the archive in the background, then present its system activity - /// sheet. The coordinator purges the previous export when this one starts. - private func runExport() { - Task { - if let url = await backup.exportBackup() { - presentedShareItem = BackupShareSheet.Item(url: url) + private static func snapshotForm(model: BackupModel, recordingEnabled: Bool) -> some View { + NavigationStack { + Form { + BackupSettingsContent( + backup: model, + recordingEnabled: recordingEnabled, + ) + } + .navigationTitle("Data") + .navigationBarTitleDisplayMode(.inline) } } } -} -#if DEBUG #Preview { Form { - BackupSettingsSection(backup: PreviewSupport.backupModel()) + BackupSettingsSection( + backup: PreviewSupport.backupModel(), + recordingEnabled: true, + ) } .whereBroadwayRoot() } diff --git a/Where/WhereUI/Sources/Settings/DataSettingsView.swift b/Where/WhereUI/Sources/Settings/DataSettingsView.swift index b9cea9ccb..171321d9a 100644 --- a/Where/WhereUI/Sources/Settings/DataSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/DataSettingsView.swift @@ -30,7 +30,10 @@ struct DataSettingsView: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets()) - BackupSettingsSection(backup: backup) + BackupSettingsSection( + backup: backup, + recordingEnabled: session.isAutomaticRecordingEnabled, + ) dataSection resetSection } @@ -111,12 +114,24 @@ extension DataSettingsView: SettingsSection { enum Item: SettingsItem { case exportBackup + case automaticBackups + case backupInterval + case recoveryKey + case copyRecoveryKey case eraseYear case resetApp var title: String { switch self { case .exportBackup: String(localized: .settingsBackupExport) + case .automaticBackups: + String(localized: "settings.backup.automatic.enabled", bundle: .module) + case .backupInterval: + String(localized: "settings.backup.automatic.interval", bundle: .module) + case .recoveryKey: + String(localized: "settings.backup.recovery.show", bundle: .module) + case .copyRecoveryKey: + String(localized: "settings.backup.recovery.copy", bundle: .module) case .eraseYear: String(localized: .settingsEraseYearTitle) case .resetApp: String(localized: .settingsResetErase) } @@ -125,6 +140,16 @@ extension DataSettingsView: SettingsSection { var keywords: [String] { switch self { case .exportBackup: splitKeywords(String(localized: .settingsKeywordsExport)) + case .automaticBackups, .backupInterval: + splitKeywords(String( + localized: "settings.keywords.automaticBackups", + bundle: .module, + )) + case .recoveryKey, .copyRecoveryKey: + splitKeywords(String( + localized: "settings.keywords.recoveryKey", + bundle: .module, + )) case .eraseYear: splitKeywords(String(localized: .settingsKeywordsEraseYear)) case .resetApp: splitKeywords(String(localized: .settingsKeywordsReset)) } diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index 6188ba031..4a3c4a676 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -36,7 +36,10 @@ struct SettingsView: View { self.recordingWarning = recordingWarning ?? RecordingConfigurationWarningModel( preferences: report.preferences, ) - _backup = State(initialValue: BackupModel(services: report.services)) + _backup = State(initialValue: BackupModel( + services: report.services, + preferences: report.preferences, + )) _reminders = State(initialValue: RemindersSettingsModel( services: report.services, preferences: report.preferences, diff --git a/Where/WhereUI/Tests/BackupModelTests.swift b/Where/WhereUI/Tests/BackupModelTests.swift index df0d5f92b..bba12dc8d 100644 --- a/Where/WhereUI/Tests/BackupModelTests.swift +++ b/Where/WhereUI/Tests/BackupModelTests.swift @@ -1,4 +1,5 @@ import Foundation +@_spi(Testing) import KeychainKit import Testing @_spi(Testing) import WhereCore @testable import WhereUI @@ -6,6 +7,62 @@ import Testing /// Exercises `BackupModel`'s Settings-only export bridge and error presentation. @MainActor struct BackupModelTests { + @Test func disappearingDuringAutomaticBackupStillPersistsTheCompletedResult() async throws { + let gate = BackupKeyAccessGate() + let root = URL.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let now = Date(timeIntervalSince1970: 1_700_000_000) + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + backupRecoveryKeys: BackupRecoveryKeyProvider( + store: InMemoryKeychainStore(), + isProtectedDataAvailable: { await gate.wait() }, + ), + automaticBackupStorage: AutomaticBackupStorage( + iCloudRoot: { nil }, + localRoot: { root }, + ), + now: { now }, + ) + let model = BackupModel(services: services, preferences: preferences) + let activation = Task { await model.activate(recordingEnabled: true) } + await gate.waitForArrival() + model.deactivate() + activation.cancel() + await gate.release() + await activation.value + #expect(preferences.lastAutomaticBackupAt == now) + #expect(model.backupError == nil) + #expect(model.catalogState == .idle) + #expect(model.revealedRecoveryKey == nil) + } + + @Test func hidingWhileKeyAccessIsPendingDiscardsTheLateResult() async throws { + let gate = BackupKeyAccessGate() + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + backupRecoveryKeys: BackupRecoveryKeyProvider( + store: InMemoryKeychainStore(), + isProtectedDataAvailable: { await gate.wait() }, + ), + automaticBackupStorage: AutomaticBackupStorage( + iCloudRoot: { nil }, + localRoot: { URL.temporaryDirectory.appendingPathComponent(UUID().uuidString) }, + ), + ) + let model = BackupModel(services: services) + let request = Task { await model.revealRecoveryKey() } + await gate.waitForArrival() + model.hideRecoveryKey() + await gate.release() + await request.value + #expect(model.revealedRecoveryKey == nil) + #expect(model.backupError == nil) + } + private func date(year: Int, month: Int, day: Int) -> Date { Calendar.current.date( from: DateComponents(year: year, month: month, day: day, hour: 12), @@ -62,6 +119,26 @@ struct BackupModelTests { #expect(backup.backupError == nil) #expect(!backup.isShowingBackupError) } + + @Test func automaticBackupChoicesMirrorAndPersist() throws { + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + ) + let backup = BackupModel(services: services, preferences: preferences) + + #expect(backup.automaticBackupsEnabled) + #expect(backup.automaticBackupInterval == .weekly) + + backup.setAutomaticBackupsEnabled(false) + backup.setAutomaticBackupInterval(.monthly) + + #expect(!backup.automaticBackupsEnabled) + #expect(backup.automaticBackupInterval == .monthly) + #expect(!preferences.automaticBackupsEnabled) + #expect(preferences.automaticBackupInterval == .monthly) + } } private struct CleanupFailure: Error {} diff --git a/Where/WhereUI/Tests/BackupSettingsContentTests.swift b/Where/WhereUI/Tests/BackupSettingsContentTests.swift new file mode 100644 index 000000000..a0f8883a9 --- /dev/null +++ b/Where/WhereUI/Tests/BackupSettingsContentTests.swift @@ -0,0 +1,24 @@ +import SwiftUI +import TestHostSupport +import Testing +import WhereCore +@testable import WhereUI + +@MainActor +struct BackupSettingsContentTests { + @Test func rehostingDisplayContentPreservesTheRevealedFixture() throws { + let model = PreviewSupport.backupModel() + let key = "VGhpcy1pcy1hLXNhbXBsZS1yZWNvdmVyeS1rZXku" + model.configurePreview( + catalogState: .loaded(AutomaticBackupCatalog(files: [], isICloudUnavailable: false)), + recoveryKey: key, + ) + for _ in 0 ..< 2 { + let content = Form { BackupSettingsContent(backup: model, recordingEnabled: true) } + try show(UIHostingController(rootView: content)) { hosted in + #expect(hosted.view != nil) + } + #expect(model.revealedRecoveryKey == key) + } + } +} diff --git a/Where/WhereUI/Tests/BackupSettingsSectionTests.swift b/Where/WhereUI/Tests/BackupSettingsSectionTests.swift index e18638bb5..78bf0acf0 100644 --- a/Where/WhereUI/Tests/BackupSettingsSectionTests.swift +++ b/Where/WhereUI/Tests/BackupSettingsSectionTests.swift @@ -7,7 +7,10 @@ import Testing struct BackupSettingsSectionTests { @Test func hostsWithABackupModel() throws { let rootView = Form { - BackupSettingsSection(backup: PreviewSupport.backupModel()) + BackupSettingsSection( + backup: PreviewSupport.backupModel(), + recordingEnabled: true, + ) } try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) diff --git a/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift b/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift index 787f6eb37..a531ee8ea 100644 --- a/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift +++ b/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift @@ -6,6 +6,29 @@ import UIKit @MainActor struct InstallationRecordingContextStoreTests { + @Test func constructionDefersReadingIdentityAndResetCleanupUntilFirstUnlock() throws { + let fixture = try makeFixture() + defer { fixture.cleanup() } + let original = fixture.makeStore() + let confirmed = try original.confirmInitialRecording(isEnabled: true) + let deferred = fixture.makeStore(defersLoading: true) + #expect(deferred.onboardingContext.automaticRecordingEnabled == nil) + #expect(throws: CocoaError(.fileReadNoPermission)) { try deferred.resolve() } + + // The first unlocked preparation reads the existing identity, not the + // proposed one cached while its protected directory was inaccessible. + try deferred.prepareAfterFirstUnlock() + #expect(try deferred.resolve() == confirmed) + try deferred.prepareAfterFirstUnlock() + #expect(try deferred.resolve() == confirmed) + + try FileManager.default.moveItem(at: fixture.directory, to: fixture.resetPendingURL) + let pendingReset = fixture.makeStore(defersLoading: true) + #expect(fixture.resetPendingExists) + try pendingReset.prepareAfterFirstUnlock() + #expect(fixture.resetPendingExists == false) + } + @Test func mapsInterfaceIdiomsToRecordingKinds() { #expect(FileInstallationRecordingContextStore.kind(for: .phone) == .phone) #expect(FileInstallationRecordingContextStore.kind(for: .pad) == .tablet) @@ -381,6 +404,7 @@ struct InstallationRecordingContextStoreTests { @MainActor func makeStore( fileManager: FileManager = .default, + defersLoading: Bool = false, ) -> FileInstallationRecordingContextStore { let sequence = IDSequence(ids) let clock = DateSequence(dates) @@ -391,6 +415,7 @@ struct InstallationRecordingContextStoreTests { kind: .phone, makeUUID: sequence.next, now: clock.next, + defersLoading: defersLoading, ) } diff --git a/Where/WhereUI/Tests/PrepareProtectedDataStepTests.swift b/Where/WhereUI/Tests/PrepareProtectedDataStepTests.swift new file mode 100644 index 000000000..54cbf4668 --- /dev/null +++ b/Where/WhereUI/Tests/PrepareProtectedDataStepTests.swift @@ -0,0 +1,24 @@ +import LifecycleKit +import Testing +@testable import WhereUI + +@MainActor +struct PrepareProtectedDataStepTests { + @Test func preparationIsAwaitedBeforeTheFollowingLaunchNode() async { + let gate = BackupKeyAccessGate() + var prepared = false + let step = PrepareProtectedDataStep { + _ = await gate.wait() + prepared = true + } + let runner = LifecycleRunner(reason: .undetermined, plan: LaunchPlan(step)) + let launch = Task { await runner.run() } + await gate.waitForArrival() + #expect(prepared == false) + #expect(runner.phase.isReady == false) + await gate.release() + await launch.value + #expect(prepared) + #expect(runner.phase.isReady) + } +} diff --git a/Where/WhereUI/Tests/Support/BackupKeyAccessGate.swift b/Where/WhereUI/Tests/Support/BackupKeyAccessGate.swift new file mode 100644 index 000000000..388ab1769 --- /dev/null +++ b/Where/WhereUI/Tests/Support/BackupKeyAccessGate.swift @@ -0,0 +1,27 @@ +import Foundation + +actor BackupKeyAccessGate { + private var continuation: CheckedContinuation? + private var arrival: CheckedContinuation? + private var hasArrived = false + private var isOpen = false + + func wait() async -> Bool { + if isOpen { return true } + hasArrived = true + arrival?.resume() + arrival = nil + return await withCheckedContinuation { continuation = $0 } + } + + func waitForArrival() async { + if hasArrived { return } + await withCheckedContinuation { arrival = $0 } + } + + func release() { + isOpen = true + continuation?.resume(returning: true) + continuation = nil + } +} diff --git a/Where/WhereUI/Tests/WhereLaunchTests.swift b/Where/WhereUI/Tests/WhereLaunchTests.swift index ae53141ae..82e3ead30 100644 --- a/Where/WhereUI/Tests/WhereLaunchTests.swift +++ b/Where/WhereUI/Tests/WhereLaunchTests.swift @@ -159,6 +159,7 @@ struct WhereLaunchTests { let model = try makeModel(preferences: makePreferences()) let ids = WhereLaunch.plan(for: model).nodeIDs #expect(ids == [ + .protectedData, .activateDemo, .onboarding, .resolveScope, diff --git a/Where/WhereUI/Tests/WhereSnapshotTests.swift b/Where/WhereUI/Tests/WhereSnapshotTests.swift new file mode 100644 index 000000000..1fc3a7207 --- /dev/null +++ b/Where/WhereUI/Tests/WhereSnapshotTests.swift @@ -0,0 +1,23 @@ +import SwiftUI +import Testing +@testable import WhereUI + +@MainActor +struct WhereSnapshotTests { + @Test func forwardsMeasurementAndCaptureReadinessHooks() async { + var readinessCalls = 0 + let snapshot = whereSnapshot( + name: "Readiness", + configurations: .fullContentPhoneLightDark, + onReadyToMeasure: { readinessCalls += 1 }, + onReadyToSnapshot: { readinessCalls += 10 }, + ) { + Text("Ready") + } + + await snapshot.onReadyToMeasure?() + #expect(readinessCalls == 1) + await snapshot.onReadyToSnapshot?() + #expect(readinessCalls == 11) + } +}