From ccc58241716cc92dc8d2e2dfdd3f05e7753fe15f Mon Sep 17 00:00:00 2001 From: sudoHG Date: Tue, 10 Nov 2026 14:01:02 +0800 Subject: [PATCH 1/4] Add the pairing-only iPhone app The app pairs an iPhone with a Mac as ADR 0013 specifies: the welcome screen checks the Secure Enclave, Face ID or Touch ID and the iCloud account, explains the wireless-data prompt, asks for notifications, then creates the three keys with the iPhone's protection and the inbox. The scanner reads QR codes only and says to scan only the code in Ask Key on the person's own Mac. The pairing screen shows the code only once the messenger reports it, after a verified challenge and a saved reveal, says that the code appears only there, and confirms with the approval key. Paired Macs can be removed; delivery checks are answered when the app opens or a push arrives, and each one is recorded without content. AskKeyiOSKit holds the logic and views; the app target only starts it. The tests run without an app host, with fakes for keys, keychain and transport and a second messenger as the Mac, and render every screen state in English and Simplified Chinese. scripts/build-ios.sh generates the project with XcodeGen, builds and tests for the simulator, and exports the maintainer's Ad Hoc build; CI runs it in the ios-app job. --- .github/workflows/ci.yml | 40 + .gitignore | 3 + docs/testing.md | 17 + iOS/AskKeyiOS/AskKeyiOS.entitlements | 20 + iOS/AskKeyiOS/AskKeyiOSApp.swift | 14 + iOS/AskKeyiOS/Info.plist | 49 + iOS/AskKeyiOS/Resources/Localizable.xcstrings | 22 + .../Resources/zh-Hans.lproj/InfoPlist.strings | 4 + iOS/AskKeyiOSKit/App/AppModel+Inbox.swift | 197 +++ iOS/AskKeyiOSKit/App/AppModel.swift | 321 ++++ iOS/AskKeyiOSKit/App/AppStates.swift | 71 + iOS/AskKeyiOSKit/App/AskKeyAppDelegate.swift | 56 + .../Pairing/PairingFlowModel.swift | 278 +++ .../Resources/Localizable.xcstrings | 1570 +++++++++++++++++ .../Services/AppEnvironment.swift | 114 ++ .../Services/DeviceKeychain.swift | 146 ++ .../Services/DeviceServices.swift | 82 + iOS/AskKeyiOSKit/Services/LiveServices.swift | 151 ++ iOS/AskKeyiOSKit/Services/LocalState.swift | 141 ++ iOS/AskKeyiOSKit/Views/Components.swift | 120 ++ iOS/AskKeyiOSKit/Views/PairedMacsView.swift | 150 ++ iOS/AskKeyiOSKit/Views/PairingFlowView.swift | 230 +++ iOS/AskKeyiOSKit/Views/QRScannerView.swift | 75 + iOS/AskKeyiOSKit/Views/RootView.swift | 77 + iOS/AskKeyiOSKit/Views/WelcomeView.swift | 79 + iOS/AskKeyiOSKitTests/Fakes/FakeCloud.swift | 167 ++ iOS/AskKeyiOSKitTests/Fakes/FakeSystem.swift | 121 ++ .../Fakes/SoftwareIdentity.swift | 122 ++ iOS/AskKeyiOSKitTests/Fakes/TestWorld.swift | 83 + .../IdentityResetTests.swift | 76 + iOS/AskKeyiOSKitTests/LocalizationTests.swift | 70 + iOS/AskKeyiOSKitTests/PairedMacsTests.swift | 124 ++ iOS/AskKeyiOSKitTests/PairingFlowTests.swift | 210 +++ iOS/AskKeyiOSKitTests/ScreenRenderTests.swift | 103 ++ iOS/AskKeyiOSKitTests/WelcomeTests.swift | 134 ++ iOS/project.yml | 83 + scripts/build-ios.sh | 166 ++ scripts/check_hygiene.py | 3 + 38 files changed, 5489 insertions(+) create mode 100644 iOS/AskKeyiOS/AskKeyiOS.entitlements create mode 100644 iOS/AskKeyiOS/AskKeyiOSApp.swift create mode 100644 iOS/AskKeyiOS/Info.plist create mode 100644 iOS/AskKeyiOS/Resources/Localizable.xcstrings create mode 100644 iOS/AskKeyiOS/Resources/zh-Hans.lproj/InfoPlist.strings create mode 100644 iOS/AskKeyiOSKit/App/AppModel+Inbox.swift create mode 100644 iOS/AskKeyiOSKit/App/AppModel.swift create mode 100644 iOS/AskKeyiOSKit/App/AppStates.swift create mode 100644 iOS/AskKeyiOSKit/App/AskKeyAppDelegate.swift create mode 100644 iOS/AskKeyiOSKit/Pairing/PairingFlowModel.swift create mode 100644 iOS/AskKeyiOSKit/Resources/Localizable.xcstrings create mode 100644 iOS/AskKeyiOSKit/Services/AppEnvironment.swift create mode 100644 iOS/AskKeyiOSKit/Services/DeviceKeychain.swift create mode 100644 iOS/AskKeyiOSKit/Services/DeviceServices.swift create mode 100644 iOS/AskKeyiOSKit/Services/LiveServices.swift create mode 100644 iOS/AskKeyiOSKit/Services/LocalState.swift create mode 100644 iOS/AskKeyiOSKit/Views/Components.swift create mode 100644 iOS/AskKeyiOSKit/Views/PairedMacsView.swift create mode 100644 iOS/AskKeyiOSKit/Views/PairingFlowView.swift create mode 100644 iOS/AskKeyiOSKit/Views/QRScannerView.swift create mode 100644 iOS/AskKeyiOSKit/Views/RootView.swift create mode 100644 iOS/AskKeyiOSKit/Views/WelcomeView.swift create mode 100644 iOS/AskKeyiOSKitTests/Fakes/FakeCloud.swift create mode 100644 iOS/AskKeyiOSKitTests/Fakes/FakeSystem.swift create mode 100644 iOS/AskKeyiOSKitTests/Fakes/SoftwareIdentity.swift create mode 100644 iOS/AskKeyiOSKitTests/Fakes/TestWorld.swift create mode 100644 iOS/AskKeyiOSKitTests/IdentityResetTests.swift create mode 100644 iOS/AskKeyiOSKitTests/LocalizationTests.swift create mode 100644 iOS/AskKeyiOSKitTests/PairedMacsTests.swift create mode 100644 iOS/AskKeyiOSKitTests/PairingFlowTests.swift create mode 100644 iOS/AskKeyiOSKitTests/ScreenRenderTests.swift create mode 100644 iOS/AskKeyiOSKitTests/WelcomeTests.swift create mode 100644 iOS/project.yml create mode 100755 scripts/build-ios.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68c7687..448b3e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,46 @@ jobs: if-no-files-found: error retention-days: 7 + ios-app: + # The pairing-only iPhone app (#289). release-gate.py does not require + # this job, but a PR that changes the app needs it green. + runs-on: macos-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Report toolchain and simulators + run: | + xcodebuild -version + xcrun simctl list runtimes + - name: Restore SwiftPM dependency cache + id: swiftpm-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/Library/Caches/org.swift.swiftpm + key: swiftpm-${{ github.job }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Package.resolved') }} + - name: Resolve package dependencies + run: swift package resolve + - name: Save SwiftPM dependency cache + if: steps.swiftpm-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/Library/Caches/org.swift.swiftpm + key: ${{ steps.swiftpm-cache.outputs.cache-primary-key }} + - name: Install XcodeGen + run: brew list xcodegen || brew install xcodegen + - name: Build the app for the iOS Simulator + run: bash scripts/build-ios.sh build + - name: Run AskKeyiOSKitTests on a simulator and render the screens + run: bash scripts/build-ios.sh test + - name: Upload the rendered screens + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: askkey-ios-screens + path: .build/ios/screens/ + if-no-files-found: error + retention-days: 14 + release-sdk-window-check: # The preview image is informational; the two macos-latest jobs remain required. runs-on: xcode-27 diff --git a/.gitignore b/.gitignore index 5538f93..323e47d 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ reviews/ # Website build tooling node_modules/ + +# Generated iPhone project (scripts/build-ios.sh); build output goes to .build/ios +iOS/*.xcodeproj/ diff --git a/docs/testing.md b/docs/testing.md index a3934fd..3685e98 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -173,6 +173,23 @@ Before building, [cloudkit-signing.py](../scripts/cloudkit-signing.py) copies th When the App carries `Contents/embedded.provisionprofile`, `package-release.sh` checks the staged copy before notarizing, also with `--no-notarize`: `ASKKEY_APPLE_TEAM_ID` is then required, the profile must still pass the checks above for the App's leaf certificate and be the one the signature sealed, the Info.plist must declare the container, and the signed entitlements must be exactly the set above. The DMG keeps the profile. +## iPhone app + +The pairing-only iPhone app (#289, ADR 0013 → iPhone app) lives in `iOS/`. [build-ios.sh](../scripts/build-ios.sh) generates `iOS/AskKeyiOS.xcodeproj` from `iOS/project.yml` with XcodeGen (the project is never committed), checks that both String Catalogs have English and Simplified Chinese with matching placeholders and that every key the kit uses exists, and keeps its build output in `.build/ios`. It needs XcodeGen, an iOS Simulator runtime and at least 80 GiB free: + +```bash +bash scripts/build-ios.sh build +bash scripts/build-ios.sh test +``` + +| Mode | Coverage and limits | +| --- | --- | +| `build` | Builds the app target `AskKeyiOS` for the iOS Simulator, unsigned. The build has no CloudKit container in its Info.plist, so it never creates one. | +| `test` | Runs `AskKeyiOSKitTests` on the newest available iPhone simulator, without an app host, and writes the rendered screens to `.build/ios/screens` (each screen state in English and Simplified Chinese). The tests drive the real `DeviceMessenger` with software keys, an in-memory keychain and an in-memory account; the Mac side is a second messenger. They do not run the Secure Enclave, Face ID, the keychain, CloudKit or push. | +| `export` | The maintainer's Ad Hoc build for #290: `ASKKEY_APPLE_TEAM_ID= bash scripts/build-ios.sh export` archives with automatic signing for that team (`-allowProvisioningUpdates`), puts `CloudKitContainerIdentifier` in the Info.plist, exports with the Production CloudKit environment, and reads back the signed entitlements (application identifier, team, `aps-environment = production`, the container and CloudKit service, the keychain access group) and the Info.plist. Agents do not run it. | + +The CI job `ios-app` runs `build` and `test` on `macos-latest` and uploads the screens as the `askkey-ios-screens` artifact; `release-gate.py` does not require it, but a PR that changes the app needs it green and the maintainer reviews the English and Chinese screens before merge. Do not launch the app in a simulator on the maintainer's Mac. Signing, the Secure Enclave, Face ID, the keychain access group, CloudKit and push are verified on a real iPhone in #290. + ## Pre-release checks on the maintainer's Mac CI cannot check focus, Touch ID or real clients. Before notarizing, review the release diff for these behaviors, then install the signed `Release` build at `/Applications/Ask Key.app` and walk through this list once with the maintainer. The planner triggers agent requests itself (for example `askkey run --wait-for-approval` with a harmless command that prints no values, or a Codex session), one request at a time with explicit instructions; delay requests that coincide with screen lock so the maintainer never waits at the lock screen. The maintainer only touches the sensor, clicks decisions and reports what they see. Notarize the same build afterwards; its signature hash must match the one tested. Device checks remain `NOT_VERIFIED` until performed; passing synthetic tests or CI is not device verification. diff --git a/iOS/AskKeyiOS/AskKeyiOS.entitlements b/iOS/AskKeyiOS/AskKeyiOS.entitlements new file mode 100644 index 0000000..41de873 --- /dev/null +++ b/iOS/AskKeyiOS/AskKeyiOS.entitlements @@ -0,0 +1,20 @@ + + + + + aps-environment + development + com.apple.developer.icloud-container-identifiers + + iCloud.com.sudohg.askkey + + com.apple.developer.icloud-services + + CloudKit + + keychain-access-groups + + $(AppIdentifierPrefix)com.sudohg.askkey.ios.shared + + + diff --git a/iOS/AskKeyiOS/AskKeyiOSApp.swift b/iOS/AskKeyiOS/AskKeyiOSApp.swift new file mode 100644 index 0000000..dfe14d2 --- /dev/null +++ b/iOS/AskKeyiOS/AskKeyiOSApp.swift @@ -0,0 +1,14 @@ +import AskKeyiOSKit +import SwiftUI + +/// Only starts the app; every behavior lives in `AskKeyiOSKit`. +@main +struct AskKeyiOSApp: App { + @UIApplicationDelegateAdaptor(AskKeyAppDelegate.self) private var delegate + + var body: some Scene { + WindowGroup { + AskKeyRootView(model: delegate.model) + } + } +} diff --git a/iOS/AskKeyiOS/Info.plist b/iOS/AskKeyiOS/Info.plist new file mode 100644 index 0000000..a8cf55f --- /dev/null +++ b/iOS/AskKeyiOS/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Ask Key + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Ask Key + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CloudKitContainerIdentifier + $(CLOUDKIT_CONTAINER_IDENTIFIER) + KeychainAccessGroup + $(AppIdentifierPrefix)com.sudohg.askkey.ios.shared + LSRequiresIPhoneOS + + NSCameraUsageDescription + Ask Key uses the camera only to scan the pairing QR code that Ask Key shows on your own Mac. + NSFaceIDUsageDescription + Ask Key uses Face ID to confirm that you approve pairing this iPhone with a Mac. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/iOS/AskKeyiOS/Resources/Localizable.xcstrings b/iOS/AskKeyiOS/Resources/Localizable.xcstrings new file mode 100644 index 0000000..5b55a61 --- /dev/null +++ b/iOS/AskKeyiOS/Resources/Localizable.xcstrings @@ -0,0 +1,22 @@ +{ + "sourceLanguage": "en", + "strings": { + "push.message.body": { + "comment": "Body of the alert CloudKit shows for a message in this iPhone's inbox. Neutral: Ask Key never asks the person to act on a notification's text.", + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "A paired device sent a message." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "一台已配对的设备发来一条消息。" } } + } + }, + "push.message.title": { + "comment": "Title of the alert CloudKit shows for a message in this iPhone's inbox.", + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Ask Key" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "请旨" } } + } + } + }, + "version": "1.0" +} diff --git a/iOS/AskKeyiOS/Resources/zh-Hans.lproj/InfoPlist.strings b/iOS/AskKeyiOS/Resources/zh-Hans.lproj/InfoPlist.strings new file mode 100644 index 0000000..d58a837 --- /dev/null +++ b/iOS/AskKeyiOS/Resources/zh-Hans.lproj/InfoPlist.strings @@ -0,0 +1,4 @@ +"CFBundleDisplayName" = "请旨"; +"CFBundleName" = "请旨"; +"NSCameraUsageDescription" = "请旨只用相机扫描你自己的 Mac 上请旨显示的配对二维码。"; +"NSFaceIDUsageDescription" = "请旨用面容 ID 确认你同意这台 iPhone 与 Mac 配对。"; diff --git a/iOS/AskKeyiOSKit/App/AppModel+Inbox.swift b/iOS/AskKeyiOSKit/App/AppModel+Inbox.swift new file mode 100644 index 0000000..d70b0b4 --- /dev/null +++ b/iOS/AskKeyiOSKit/App/AppModel+Inbox.swift @@ -0,0 +1,197 @@ +import AskKeyDevices +import Foundation + +extension AppModel { + /// The most delivery records kept. + static let deliveryRecordLimit = 50 + /// How long the app looks for the message a push pointed to. + static let pushHintLifetime = MessageKind.deliveryCheckLifetime + 5 * 60 + /// How long a reset waits for CloudKit to delete the inbox. + static let inboxRemovalTimeout: TimeInterval = 10 + + /// The app became active: collect the pushes still in Notification + /// Center, then process the inbox. + public func becameActive() async { + guard stage == .ready, let services else { return } + remember(await environment.notifications.deliveredPushHints(for: services.deviceID)) + await processInbox() + } + + /// A push arrived while the app runs, or the person opened one. It is + /// only a hint: the app fetches its inbox and acts on what it verifies, + /// never on the notification's text, category or action. + public func receivedPush(userInfo: [AnyHashable: Any], arrivedAt: Date, notificationIdentifier: String?) async { + guard let services else { return } + guard let hint = PushHintReader.hint(userInfo: userInfo, arrivedAt: arrivedAt, + notificationIdentifier: notificationIdentifier, + for: services.deviceID) else { return } + remember([hint]) + await processInbox() + } + + func remember(_ hints: [PushHint]) { + let now = environment.clock.now() + var stale: [String] = [] + for hint in hints where pushHints[hint.recordName] == nil { + pushHints[hint.recordName] = hint + } + for (name, hint) in pushHints where now.timeIntervalSince(hint.arrivedAt) > Self.pushHintLifetime { + pushHints[name] = nil + if let identifier = hint.notificationIdentifier { stale.append(identifier) } + } + environment.notifications.removeDeliveredNotifications(identifiers: stale) + } + + /// Fetches and processes the inbox: challenges, confirmations, + /// cancellations, delivery checks (answered at once with the message key, + /// without a prompt) and unpair notices. Calls made while a fetch runs + /// wait for it and fetch once more afterwards. + public func processInbox() async { + if let running = inboxWork { + pollAgain = true + await running.value + return + } + let work = Task { [weak self] in + guard let self else { return } + repeat { + self.pollAgain = false + await self.pollOnce() + } while self.pollAgain && self.stage == .ready + } + inboxWork = work + await work.value + inboxWork = nil + } + + private func pollOnce() async { + guard stage == .ready, let services else { return } + if !accountChecked || accountNotice != nil { await refreshAccount() } + guard accountNotice == nil, self.services === services else { return } + let before = try? await services.registry.list() + let report: InboxReport + do { + report = try await services.messenger.poll() + } catch is DeviceIdentityError { + guard self.services === services else { return } + identityBecameUnusable() + return + } catch { + AppLog.failure("Fetching the inbox", error) + return + } + guard self.services === services else { return } + if let token = await services.messenger.inboxToken, var position = environment.localState.inboxPosition { + position.token = token.data + environment.localState.inboxPosition = position + } + recordDeliveries(report, kinds: services.transport.takeKinds()) + AppLog.refusals(report.refusals) + await refreshList(previous: before) + } + + /// Records each answered delivery check, found through a push when one + /// pointed to its record and by polling otherwise, and clears the + /// notifications of messages that are settled. + func recordDeliveries(_ report: InboxReport, kinds: [String: Int64]) { + let now = environment.clock.now() + var settled: [String] = [] + for name in report.outcomes.keys.sorted() { + switch report.outcomes[name] { + case .processed?: + if kinds[name] == Int64(MessageKind.deliveryCheck.rawValue) { + let hint = pushHints[name] + append(DeliveryRecord(recordName: name, arrivedAt: hint?.arrivedAt ?? now, + source: hint == nil ? .polling : .push)) + } + case .refused?: + break + default: + continue + } + if let identifier = pushHints.removeValue(forKey: name)?.notificationIdentifier { + settled.append(identifier) + } + } + environment.notifications.removeDeliveredNotifications(identifiers: settled) + } + + private func append(_ record: DeliveryRecord) { + AppLog.delivery(record) + deliveryRecords = Array((deliveryRecords + [record]).suffix(Self.deliveryRecordLimit)) + environment.localState.deliveryRecords = deliveryRecords + } + + // MARK: Reset + + /// Deletes the keys and the paired-device list after stopping the device + /// services, deletes this device's inbox and subscriptions when CloudKit + /// answers in time, and returns to the welcome screen. + public func resetIdentity() async { + guard stage == .unusableIdentity, !isResetting else { return } + isResetting = true + resetFailed = false + defer { isResetting = false } + pairing = nil + let services = self.services + self.services = nil + await inboxWork?.value + let transport: (any InboxTransport)? = services?.transport + ?? environment.localState.deviceID.flatMap(DeviceID.init(text:)).flatMap(environment.cloud.makeTransport(for:)) + do { + try keychain.deleteEverything() + } catch { + AppLog.failure("Deleting the keys", error) + resetFailed = true + return + } + environment.localState.removeAll() + deliveryRecords = [] + pairedMacs = [] + pushHints = [:] + locallyRemoved = [] + listNotice = nil + accountNotice = nil + accountChecked = false + pendingSetup = nil + if let transport { + await Self.waitAtMost(Self.inboxRemovalTimeout) { + do { + try await transport.removeInbox() + } catch { + AppLog.failure("Deleting the inbox", error) + } + } + } + welcomeStep = .intro + stage = .welcome + } + + /// Waits for `operation`, but no longer than `seconds`; the operation + /// keeps running in the background after that. + static func waitAtMost(_ seconds: TimeInterval, _ operation: @escaping @Sendable () async -> Void) async { + let gate = ResumeOnce() + await withCheckedContinuation { (continuation: CheckedContinuation) in + Task { + await operation() + if gate.claim() { continuation.resume() } + } + Task { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + if gate.claim() { continuation.resume() } + } + } + } +} + +private final class ResumeOnce: @unchecked Sendable { + private let lock = NSLock() + private var claimed = false + + func claim() -> Bool { + lock.withLock { + defer { claimed = true } + return !claimed + } + } +} diff --git a/iOS/AskKeyiOSKit/App/AppModel.swift b/iOS/AskKeyiOSKit/App/AppModel.swift new file mode 100644 index 0000000..d45efa1 --- /dev/null +++ b/iOS/AskKeyiOSKit/App/AppModel.swift @@ -0,0 +1,321 @@ +import AskKeyDevices +import Foundation +import Observation + +/// The app's state: the welcome checks and identity creation, the paired +/// Macs, the pairing flow, inbox processing and the identity reset. Only +/// this model, in the app process, writes the paired-device list. +@MainActor +@Observable +public final class AppModel { + public internal(set) var stage: AppStage = .starting + public internal(set) var welcomeStep: WelcomeStep = .intro + public internal(set) var pairedMacs: [PairedMacRow] = [] + public internal(set) var accountNotice: AccountNotice? + public var listNotice: ListNotice? + public internal(set) var deliveryRecords: [DeliveryRecord] = [] + public internal(set) var isResetting = false + public internal(set) var resetFailed = false + /// The pairing flow while its sheet is open. + public var pairing: PairingFlowModel? + + @ObservationIgnored let environment: AppEnvironment + @ObservationIgnored var services: DeviceServices? + @ObservationIgnored var pendingSetup: PendingSetup? + @ObservationIgnored var accountChecked = false + @ObservationIgnored var pushHints: [String: PushHint] = [:] + /// Macs removed on this iPhone, so their disappearance is not reported + /// as a removal by the Mac. + @ObservationIgnored var locallyRemoved: Set = [] + @ObservationIgnored var inboxWork: Task? + @ObservationIgnored var pollAgain = false + @ObservationIgnored private var accountWatch: Task? + @ObservationIgnored private var started = false + + public init(environment: AppEnvironment) { + self.environment = environment + deliveryRecords = environment.localState.deliveryRecords + } + + var keychain: IdentityKeychain { IdentityKeychain(store: environment.keychain) } + var listStore: PairedDeviceListKeychainStore { PairedDeviceListKeychainStore(store: environment.keychain) } + + // MARK: Launch + + /// Restores a stored identity, or shows the welcome screen. Nothing here + /// creates a CloudKit container unless an identity exists, which only + /// the welcome checks create. + public func start() async { + guard !started else { return } + started = true + let blobs: DeviceIdentityKeyBlobs? + do { + blobs = try keychain.loadBlobs() + } catch { + AppLog.failure("Reading the keys", error) + stage = error is StoredIdentityError ? .unusableIdentity : .welcome + if stage == .welcome { welcomeStep = .failed(.keychainFailed) } + return + } + guard let blobs else { + stage = .welcome + return + } + let identity: any DeviceIdentity + do { + identity = try environment.identities.restore(blobs) + _ = try listStore.loadPairedDeviceList() + } catch { + AppLog.failure("Restoring the identity", error) + stage = .unusableIdentity + return + } + let deviceID = DeviceID(messagePublicKey: identity.messagePublicKey) + guard let transport = environment.cloud.makeTransport(for: deviceID) else { + stage = .welcome + welcomeStep = .failed(.cloudNotConfigured) + return + } + environment.localState.deviceID = deviceID.text + let token = environment.localState.inboxPosition?.token.map(InboxChangeToken.init(data:)) + await activate(DeviceServices(identity: identity, transport: transport, listStore: listStore, + clock: environment.clock, inboxToken: token)) + await environment.notifications.registerAtLaunch() + } + + func activate(_ services: DeviceServices) async { + self.services = services + stage = .ready + watchAccount() + await refreshList() + } + + // MARK: Welcome + + /// Runs the welcome checks in order, then creates the three keys, stores + /// them and an empty list, and creates the inbox and subscriptions. The + /// first failed check stops it with its own explanation. + public func runWelcomeChecks() async { + guard stage == .welcome, welcomeStep != .checking else { return } + welcomeStep = .checking + // A keychain that could not be read at launch may hold an identity: + // restore it instead of ever replacing it. + if pendingSetup == nil { + do { + if try keychain.loadBlobs() != nil { + welcomeStep = .intro + started = false + await start() + return + } + } catch { + AppLog.failure("Reading the keys", error) + welcomeStep = error is StoredIdentityError ? .intro : .failed(.keychainFailed) + if error is StoredIdentityError { stage = .unusableIdentity } + return + } + } + if let failure = await setUp() { + welcomeStep = .failed(failure) + } else { + welcomeStep = .intro + } + } + + final class PendingSetup { + let identity: any DeviceIdentity + let blobs: DeviceIdentityKeyBlobs + let transport: any InboxTransport + var stored = false + + init(identity: any DeviceIdentity, blobs: DeviceIdentityKeyBlobs, transport: any InboxTransport) { + self.identity = identity + self.blobs = blobs + self.transport = transport + } + } + + private func setUp() async -> WelcomeFailure? { + guard environment.identities.isSecureEnclaveAvailable else { return .noSecureEnclave } + switch environment.biometry.status() { + case .enrolled: break + case .notEnrolled(let kind): return .biometryNotEnrolled(kind) + case .passcodeNotSet: return .passcodeNotSet + case .deniedForApp(let kind): return .biometryDenied(kind) + case .unavailable: return .noBiometry + } + guard environment.cloud.isConfigured else { return .cloudNotConfigured } + let setup: PendingSetup + if let pendingSetup { + setup = pendingSetup + } else { + // The keys exist only in memory until every check passed; a + // failed check discards them. + let created: (identity: any DeviceIdentity, blobs: DeviceIdentityKeyBlobs) + do { + created = try environment.identities.create() + } catch DeviceIdentityError.secureEnclaveUnavailable { + return .noSecureEnclave + } catch { + AppLog.failure("Creating the keys", error) + return .keyCreationFailed + } + let deviceID = DeviceID(messagePublicKey: created.identity.messagePublicKey) + guard let transport = environment.cloud.makeTransport(for: deviceID) else { return .cloudNotConfigured } + setup = PendingSetup(identity: created.identity, blobs: created.blobs, transport: transport) + } + let userRecordName: String + switch await setup.transport.accountState(storedUserRecordName: nil) { + case .available(let name), .accountChanged(let name): userRecordName = name + case .noAccount: return .iCloudSignedOut + case .restricted: return .iCloudRestricted + case .temporarilyUnavailable: return .iCloudUnavailable + } + await environment.notifications.requestAuthorizationAndRegister() + let deviceID = DeviceID(messagePublicKey: setup.identity.messagePublicKey) + if !setup.stored { + do { + try keychain.save(setup.blobs) + try listStore.savePairedDeviceList(PairedDeviceList()) + } catch { + AppLog.failure("Storing the keys", error) + try? keychain.deleteEverything() + return .keychainFailed + } + setup.stored = true + pendingSetup = setup + environment.localState.deviceID = deviceID.text + } + environment.localState.inboxPosition = InboxPosition(userRecordName: userRecordName, token: nil) + do { + guard try await setup.transport.prepareInbox().isComplete else { return .iCloudFailed } + } catch DeviceTransportError.networkUnavailable { + return .networkUnavailable + } catch { + AppLog.failure("Creating the inbox", error) + return .iCloudFailed + } + pendingSetup = nil + accountChecked = true + await activate(DeviceServices(identity: setup.identity, transport: setup.transport, listStore: listStore, + clock: environment.clock, inboxToken: nil)) + return nil + } + + // MARK: Account + + private func watchAccount() { + accountWatch?.cancel() + let changes = environment.cloud.accountChanges() + accountWatch = Task { [weak self] in + for await _ in changes { + guard let self else { return } + await self.refreshAccount() + } + } + } + + /// Compares the signed-in account with the stored one. After a change + /// the old token and any pairing session are dropped and the inbox is + /// created in the new account; the list is kept. Without an account, + /// every paired Mac is unreachable until the person signs back in. + func refreshAccount() async { + guard let services else { return } + let stored = environment.localState.inboxPosition?.userRecordName + let state = await services.transport.accountState(storedUserRecordName: stored) + guard self.services === services else { return } + accountChecked = true + switch state { + case .available(let name): + accountNotice = nil + if stored == nil { environment.localState.inboxPosition = InboxPosition(userRecordName: name, token: nil) } + case .accountChanged(let name): + accountNotice = nil + environment.localState.inboxPosition = InboxPosition(userRecordName: name, token: nil) + closePairing() + services.restartMessenger() + case .noAccount: + accountNotice = .signedOut + return + case .restricted: + accountNotice = .restricted + return + case .temporarilyUnavailable: + accountNotice = .unavailable + return + } + do { + _ = try await services.transport.prepareInbox() + } catch { + AppLog.failure("Preparing the inbox", error) + } + } + + // MARK: Pairing + + public func startPairing() { + guard stage == .ready, let services, pairing == nil else { return } + pairing = PairingFlowModel(messenger: services.messenger, camera: environment.camera, + deviceName: environment.deviceName(), host: self) + } + + /// Closes the pairing sheet. An open session is cancelled, and the Mac told. + public func closePairing() { + Task { await closePairingAndWait() } + } + + func closePairingAndWait() async { + guard let flow = pairing else { return } + pairing = nil + await flow.cancelIfOpen() + await refreshList() + } + + func pairingSucceeded(with peer: DeviceID) { + locallyRemoved.remove(peer) + Task { await refreshList() } + } + + func identityBecameUnusable() { + pairing = nil + stage = .unusableIdentity + } + + // MARK: Paired Macs + + /// Removes the Mac at once, then sends it an unpair notice. + public func removeMac(id: String) async { + guard let services, let deviceID = DeviceID(text: id), + let row = pairedMacs.first(where: { $0.id == id }) else { return } + locallyRemoved.insert(deviceID) + do { + let told = try await services.messenger.removeDevice(deviceID) + listNotice = .removed(name: row.name, macWasTold: told) + } catch DeviceMessengerError.unknownDevice { + // Already gone, for example because the Mac removed this iPhone first. + } catch { + AppLog.failure("Removing a paired Mac", error) + listNotice = .removalFailed + } + await refreshList() + } + + /// Reloads the list. Entries that disappeared since `previous` and were + /// not removed here were removed by their Mac's unpair notice. + func refreshList(previous: PairedDeviceList? = nil) async { + guard let services else { return } + do { + let list = try await services.registry.list() + guard self.services === services else { return } + for device in previous?.devices ?? [] + where list.device(device.deviceID) == nil && !locallyRemoved.contains(device.deviceID) { + listNotice = .removedByMac(name: device.displayName) + } + pairedMacs = list.devices.map(PairedMacRow.init) + } catch PairedDeviceListError.malformed { + identityBecameUnusable() + } catch { + AppLog.failure("Reading the paired-device list", error) + } + } +} diff --git a/iOS/AskKeyiOSKit/App/AppStates.swift b/iOS/AskKeyiOSKit/App/AppStates.swift new file mode 100644 index 0000000..d57f9df --- /dev/null +++ b/iOS/AskKeyiOSKit/App/AppStates.swift @@ -0,0 +1,71 @@ +import AskKeyDevices +import Foundation + +public enum AppStage: Hashable, Sendable { + case starting + case welcome + case ready + /// A stored key no longer works; only a reset helps. + case unusableIdentity +} + +public enum WelcomeStep: Hashable, Sendable { + case intro + case checking + case failed(WelcomeFailure) +} + +/// Each failed welcome check, with its own explanation. +public enum WelcomeFailure: Hashable, Sendable { + case noSecureEnclave + case biometryNotEnrolled(BiometryKind) + case passcodeNotSet + case biometryDenied(BiometryKind) + case noBiometry + /// This build carries no CloudKit container. + case cloudNotConfigured + case iCloudSignedOut + case iCloudRestricted + case iCloudUnavailable + case keyCreationFailed + case keychainFailed + /// CloudKit could not be reached, for example before the person allowed + /// the app to use wireless data. + case networkUnavailable + case iCloudFailed +} + +/// Why paired Macs cannot reach this iPhone right now. The list is kept. +public enum AccountNotice: Hashable, Sendable { + case signedOut + case restricted + case unavailable +} + +public enum ListNotice: Hashable, Sendable { + /// Removed here; `macWasTold` tells whether the unpair notice was sent. + case removed(name: String, macWasTold: Bool) + /// The Mac removed this iPhone and its unpair notice arrived. + case removedByMac(name: String) + case removalFailed +} + +public struct PairedMacRow: Identifiable, Hashable, Sendable { + public let id: String + public let name: String + public let pairedAt: Date + /// Nil until the first delivery check from that Mac arrived. + public let lastReachedAt: Date? + + public init(id: String, name: String, pairedAt: Date, lastReachedAt: Date?) { + self.id = id + self.name = name + self.pairedAt = pairedAt + self.lastReachedAt = lastReachedAt + } + + init(_ device: PairedDevice) { + self.init(id: device.deviceID.text, name: device.displayName, pairedAt: device.pairedAt, + lastReachedAt: device.lastReachedAt) + } +} diff --git a/iOS/AskKeyiOSKit/App/AskKeyAppDelegate.swift b/iOS/AskKeyiOSKit/App/AskKeyAppDelegate.swift new file mode 100644 index 0000000..0a4836b --- /dev/null +++ b/iOS/AskKeyiOSKit/App/AskKeyAppDelegate.swift @@ -0,0 +1,56 @@ +import Foundation +import UIKit +import UserNotifications + +/// Connects the system's app and notification callbacks to `AppModel`. A +/// notification is only a hint to fetch the inbox; its text, category and +/// action are never acted on. +public final class AskKeyAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { + @MainActor public private(set) lazy var model = AppModel(environment: .live()) + + public func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + // Set before launch finishes, so opening a notification reaches the app. + UNUserNotificationCenter.current().delegate = self + return true + } + + public func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + AppLog.failure("Registering for remote notifications", error) + } + + public func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + let arrivedAt = Date() + Task { @MainActor in + await model.receivedPush(userInfo: userInfo, arrivedAt: arrivedAt, notificationIdentifier: nil) + completionHandler(.newData) + } + } + + /// A push for the app in front: fetch at once and show no banner. + public func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + let userInfo = notification.request.content.userInfo + let arrivedAt = notification.date + let identifier = notification.request.identifier + completionHandler([]) + Task { @MainActor in + await model.receivedPush(userInfo: userInfo, arrivedAt: arrivedAt, notificationIdentifier: identifier) + } + } + + /// The person opened a notification: the app opens and fetches its + /// inbox; which action was chosen does not matter. + public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + let notification = response.notification + let userInfo = notification.request.content.userInfo + let arrivedAt = notification.date + let identifier = notification.request.identifier + completionHandler() + Task { @MainActor in + await model.receivedPush(userInfo: userInfo, arrivedAt: arrivedAt, notificationIdentifier: identifier) + } + } +} diff --git a/iOS/AskKeyiOSKit/Pairing/PairingFlowModel.swift b/iOS/AskKeyiOSKit/Pairing/PairingFlowModel.swift new file mode 100644 index 0000000..ef8b9e7 --- /dev/null +++ b/iOS/AskKeyiOSKit/Pairing/PairingFlowModel.swift @@ -0,0 +1,278 @@ +import AskKeyDevices +import Foundation +import Observation + +/// The responder's side of pairing (ADR 0013 → Pairing), from scanning the +/// Mac's QR code to the Mac's verified confirmation. +/// +/// The code is shown only on the pairing screen, and only once the +/// messenger reports `showingCode`, which it does after a verified challenge +/// that matches the QR code and after CloudKit saved this iPhone's reveal. +/// The code never goes into a notification, a log or a test identifier. +@MainActor +@Observable +public final class PairingFlowModel: Identifiable { + public enum Stage: Hashable, Sendable { + case scanning + /// The scanned code is being checked and the request created. + case sending + /// The request is sent; there is no code yet. + case waitingForCode + case code(PairingCodeDisplay) + /// The Mac's verified confirmation arrived; the Mac is on the list. + case paired(macName: String) + case failed(PairingFailure) + } + + public let id = UUID() + public internal(set) var stage: Stage = .scanning + public internal(set) var scanProblem: ScanProblem? + public internal(set) var confirmProblem: ConfirmProblem? + public internal(set) var isConfirming = false + public internal(set) var camera: CameraAccess + + @ObservationIgnored private let messenger: DeviceMessenger + @ObservationIgnored private let cameraAccess: any CameraAccessChecking + @ObservationIgnored private let deviceName: String + @ObservationIgnored private weak var host: AppModel? + @ObservationIgnored private var joined = false + /// The last refused text, so a code that stays in view is not handled again. + @ObservationIgnored private var rejectedText: String? + @ObservationIgnored private var finished = false + + /// How often the inbox is fetched while the pairing screen is open. + static let pollInterval: TimeInterval = 1 + + init(messenger: DeviceMessenger, camera: any CameraAccessChecking, deviceName: String, host: AppModel?) { + self.messenger = messenger + cameraAccess = camera + self.camera = camera.access() + self.deviceName = deviceName + self.host = host + } + + /// Whether closing the sheet would leave a session open; the sheet then + /// offers only Cancel. + public var isSessionOpen: Bool { + switch stage { + case .sending, .waitingForCode, .code: return true + case .scanning, .paired, .failed: return false + } + } + + // MARK: Scanning + + /// Asks for camera access the first time the scanner opens. + public func prepareCamera() async { + camera = cameraAccess.access() + guard camera == .notDetermined else { return } + _ = await cameraAccess.requestAccess() + camera = cameraAccess.access() + } + + /// A QR code was read. Anything but a current Ask Key pairing code is + /// refused with its own message, and the scanner keeps running. + public func handleScanned(_ text: String) async { + guard stage == .scanning, !finished, text != rejectedText else { return } + stage = .sending + scanProblem = nil + do { + try await messenger.joinPairing(scannedText: text, displayName: deviceName) + joined = true + // Cancel was chosen while the request was being created. + guard !finished else { + await messenger.cancelPairing() + return + } + stage = .waitingForCode + await refresh() + } catch { + stage = .scanning + if error as? DeviceIdentityError == .unusable { + host?.identityBecameUnusable() + return + } + rejectedText = text + scanProblem = ScanProblem(error) + if scanProblem == .failed { AppLog.failure("Starting pairing", error) } + } + } + + public func dismissScanProblem() { + scanProblem = nil + rejectedText = nil + } + + // MARK: Session + + /// Fetches the inbox about once a second while the screen is open; the + /// messenger's timers withdraw a request that got no challenge within 60 + /// seconds and end the session at its expiry. + public func run() async { + while !Task.isCancelled, !finished { + if joined { await refresh() } + try? await Task.sleep(nanoseconds: UInt64(Self.pollInterval * 1_000_000_000)) + } + } + + /// One fetch, then the screen follows the session's phase. + func refresh() async { + guard joined, !finished else { return } + if let host { + await host.processInbox() + } else { + _ = try? await messenger.poll() + } + // The timers also run when no fetch could, for example while iCloud + // is unavailable, so a request without a challenge is still withdrawn. + await messenger.advanceTimers() + await followPhase() + } + + private func followPhase() async { + guard joined, !finished else { return } + let phase = await messenger.pairingPhase + switch phase { + case nil, .waitingForChallenge?: + stage = .waitingForCode + case .showingCode(let code, let peer, let confirmed)?: + stage = .code(PairingCodeDisplay(digits: code.formatted, macName: peer.displayName, confirmed: confirmed)) + case .paired(let peer, _)?: + finished = true + stage = .paired(macName: peer.displayName) + host?.pairingSucceeded(with: peer.deviceID) + case .ended(let reason)?: + end(reason) + default: + // Initiator phases never occur on the responder. + break + } + } + + private func end(_ reason: PairingEndReason) { + finished = true + switch reason { + case .identityUnusable: + host?.identityBecameUnusable() + case .cancelled: + // This side cancelled; the sheet is closing. + break + default: + stage = .failed(PairingFailure(reason)) + } + } + + /// Signs the confirmation with the approval key, which asks for Face ID + /// or Touch ID every time with no passcode button. A cancelled or failed + /// prompt sends nothing; the person can confirm again. + public func confirm() async { + guard case .code(let display) = stage, !display.confirmed, !isConfirming, !finished else { return } + isConfirming = true + confirmProblem = nil + defer { isConfirming = false } + let reason = String(localized: "pairing.confirm.reason \(display.macName)", bundle: .kit) + do { + try await messenger.confirmPairing(approvalReason: reason) + } catch let error as DeviceIdentityError { + switch error { + case .authenticationCancelled: confirmProblem = .cancelled + case .authenticationFailed: confirmProblem = .failed + case .authenticationUnavailable: confirmProblem = .unavailable + case .unusable, .secureEnclaveUnavailable, .keyCreationFailed: + finished = true + host?.identityBecameUnusable() + return + } + } catch { + // The session moved on (it ended, or this side already + // confirmed); the phase below shows where it stands. + } + await followPhase() + } + + /// Cancel: ends the session and tells the Mac, by a withdrawal when no + /// challenge has arrived. It changes nothing once the Mac is on the list. + func cancelIfOpen() async { + guard joined, !finished else { + finished = true + return + } + finished = true + await messenger.cancelPairing() + } +} + +/// What the pairing screen shows. Like `PairingCode`, its description and +/// mirror are redacted, so printing a stage never reveals the code. +public struct PairingCodeDisplay: Hashable, Sendable, CustomStringConvertible, CustomDebugStringConvertible, + CustomReflectable { + /// Two groups of three digits, `123 456`. + public let digits: String + public let macName: String + /// This iPhone confirmed; the code stays until the Mac confirms too. + public let confirmed: Bool + + public init(digits: String, macName: String, confirmed: Bool) { + self.digits = digits + self.macName = macName + self.confirmed = confirmed + } + + public var description: String { "PairingCodeDisplay(redacted, confirmed: \(confirmed))" } + public var debugDescription: String { description } + public var customMirror: Mirror { Mirror(self, children: [:]) } +} + +public enum ScanProblem: Hashable, Sendable { + /// Not an Ask Key pairing code at all. + case notAPairingCode + /// It starts like one but is damaged or from another version. + case invalid + case expired + /// The code holds this iPhone's own keys or a paired device's. + case keyConflict + case deviceLimitReached + case failed + + init(_ error: Error) { + switch error { + case PairingQRPayloadError.notAPairingCode: self = .notAPairingCode + case PairingQRPayloadError.malformed: self = .invalid + case PairingQRPayloadError.expired: self = .expired + case DeviceMessengerError.keyConflict: self = .keyConflict + case DeviceMessengerError.deviceLimitReached: self = .deviceLimitReached + default: self = .failed + } + } +} + +public enum ConfirmProblem: Hashable, Sendable { + case cancelled + case failed + /// Face ID or Touch ID is off for Ask Key, or locked after failed attempts. + case unavailable +} + +public enum PairingFailure: Hashable, Sendable { + /// No challenge within 60 seconds: the request was withdrawn. The person + /// cancels on the Mac and enters no code there. + case noChallenge + case cancelledByMac + case expired + case conflictingChallenge + case deviceLimitReached + case keyConflict + case ended + + init(_ reason: PairingEndReason) { + switch reason { + case .noChallenge: self = .noChallenge + case .cancelledByPeer, .undoneByPeer: self = .cancelledByMac + case .expired: self = .expired + case .conflictingChallenge: self = .conflictingChallenge + case .deviceLimitReached: self = .deviceLimitReached + case .keyConflict: self = .keyConflict + default: self = .ended + } + } +} diff --git a/iOS/AskKeyiOSKit/Resources/Localizable.xcstrings b/iOS/AskKeyiOSKit/Resources/Localizable.xcstrings new file mode 100644 index 0000000..8b5fa55 --- /dev/null +++ b/iOS/AskKeyiOSKit/Resources/Localizable.xcstrings @@ -0,0 +1,1570 @@ +{ + "sourceLanguage": "en", + "strings": { + "common.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + } + } + }, + "common.dismiss": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dismiss" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭" + } + } + } + }, + "common.done": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "完成" + } + } + } + }, + "list.account.restricted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "iCloud is restricted on this iPhone, so your Macs can't reach it." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这台 iPhone 的 iCloud 受到限制,Mac 无法联系它。" + } + } + } + }, + "list.account.signedOut": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "iCloud is signed out, so your Macs can't reach this iPhone. Sign in with the same Apple Account as your Mac." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "iCloud 已退出登录,Mac 无法联系这台 iPhone。请用与 Mac 相同的 Apple 账户登录。" + } + } + } + }, + "list.account.unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "iCloud isn't available right now, so your Macs can't reach this iPhone." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "iCloud 暂时不可用,Mac 无法联系这台 iPhone。" + } + } + } + }, + "list.deliveries.polling %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · found when Ask Key checked its inbox" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ · 请旨检查收件箱时发现" + } + } + } + }, + "list.deliveries.push %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · arrived through a notification" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ · 通过通知送达" + } + } + } + }, + "list.deliveries.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delivery Checks" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "送达检查" + } + } + } + }, + "list.empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No paired Macs yet. On your Mac, open Ask Key's Settings, choose Devices, then Pair a Device." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "还没有配对的 Mac。请在 Mac 上打开请旨的“设置”,选择“设备”,再选择“配对设备”。" + } + } + } + }, + "list.lastReached %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Last reached %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上次联系:%@" + } + } + } + }, + "list.notReached": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not reached yet" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尚未联系" + } + } + } + }, + "list.notice.removalFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Mac couldn't be removed. Try again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法移除这台 Mac。请重试。" + } + } + } + }, + "list.notice.removedAndTold %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Removed “%@”. Ask Key told the Mac." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已移除“%@”,并已通知这台 Mac。" + } + } + } + }, + "list.notice.removedByMac %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "“%@” removed this iPhone." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "“%@”已移除这台 iPhone。" + } + } + } + }, + "list.notice.removedNotTold %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Removed “%@”, but the Mac couldn't be told. Remove this iPhone in Ask Key on the Mac too." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已移除“%@”,但没能通知这台 Mac。请也在 Mac 上的请旨中移除这台 iPhone。" + } + } + } + }, + "list.pair": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pair a Mac" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配对 Mac" + } + } + } + }, + "list.pairedOn %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paired %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配对于 %@" + } + } + } + }, + "list.remove": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + } + } + }, + "list.remove.confirm": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + } + } + }, + "list.remove.confirmMessage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This iPhone stops trusting this Mac at once. Ask Key also tells the Mac, which removes this iPhone when it next checks." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这台 iPhone 会立即不再信任这台 Mac。请旨也会通知 Mac,Mac 下次检查时会移除这台 iPhone。" + } + } + } + }, + "list.remove.confirmTitle %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove “%@”?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除“%@”?" + } + } + } + }, + "list.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paired Macs" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已配对的 Mac" + } + } + } + }, + "pairing.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel Pairing" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消配对" + } + } + } + }, + "pairing.code.instruction %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter this code in Ask Key on “%@”." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请在“%@”上的请旨中输入这个配对码。" + } + } + } + }, + "pairing.code.onlyHere": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The pairing code appears only on this screen. Ask Key never sends it in a notification or a message." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配对码只会显示在这个页面上。请旨绝不会通过通知或消息发送配对码。" + } + } + } + }, + "pairing.confirm": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm Pairing" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认配对" + } + } + } + }, + "pairing.confirm.cancelled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Face ID or Touch ID was cancelled. Nothing was sent; you can confirm again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "面容 ID 或触控 ID 已取消,没有发送任何内容。你可以再次确认。" + } + } + } + }, + "pairing.confirm.failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Face ID or Touch ID didn't succeed. Nothing was sent; you can confirm again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "面容 ID 或触控 ID 验证未通过,没有发送任何内容。你可以再次确认。" + } + } + } + }, + "pairing.confirm.footnote": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirming asks for Face ID or Touch ID." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认时需要面容 ID 或触控 ID。" + } + } + } + }, + "pairing.confirm.reason %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm pairing with “%@”" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认与“%@”配对" + } + } + } + }, + "pairing.confirm.unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Face ID or Touch ID isn't available to Ask Key right now. Check Settings → Ask Key. Nothing was sent." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请旨暂时无法使用面容 ID 或触控 ID。请检查“设置”→“请旨”。没有发送任何内容。" + } + } + } + }, + "pairing.confirmed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirmed on this iPhone. Enter the code on your Mac to finish pairing." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已在这台 iPhone 上确认。请在 Mac 上输入配对码以完成配对。" + } + } + } + }, + "pairing.failed.cancelledByMac": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pairing was cancelled on the Mac. Nothing was stored." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配对已在 Mac 上取消,没有保存任何内容。" + } + } + } + }, + "pairing.failed.conflictingChallenge": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pairing stopped because a different answer arrived for this pairing. Nothing was stored. Cancel on your Mac and start again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这次配对收到了另一个不同的回应,配对已停止,没有保存任何内容。请在 Mac 上取消后重新开始。" + } + } + } + }, + "pairing.failed.deviceLimitReached": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This iPhone is already paired with 8 Macs. Remove one, then pair again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这台 iPhone 已与 8 台 Mac 配对。请先移除一台,再重新配对。" + } + } + } + }, + "pairing.failed.ended": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pairing ended. Nothing was stored." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配对已结束,没有保存任何内容。" + } + } + } + }, + "pairing.failed.expired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The pairing session expired. Nothing was stored. Start again on your Mac." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配对已超时,没有保存任何内容。请在 Mac 上重新开始。" + } + } + } + }, + "pairing.failed.keyConflict": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This Mac's keys conflict with a device that is already paired. Nothing was stored." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这台 Mac 的密钥与已配对的设备冲突,没有保存任何内容。" + } + } + } + }, + "pairing.failed.noChallenge": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your Mac didn't answer within 60 seconds, so this iPhone withdrew its request. Cancel pairing on your Mac, and don't enter any code there." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Mac 在 60 秒内没有回应,这台 iPhone 已撤回请求。请在 Mac 上取消配对,不要在 Mac 上输入任何配对码。" + } + } + } + }, + "pairing.failed.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pairing Failed" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配对失败" + } + } + } + }, + "pairing.noCode.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for your Mac to answer. Don't enter any code on your Mac until it appears here." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在等待 Mac 回应。配对码出现在这里之前,不要在 Mac 上输入任何配对码。" + } + } + } + }, + "pairing.noCode.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No code yet" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "还没有配对码" + } + } + } + }, + "pairing.paired.detail %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This iPhone and “%@” are paired." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这台 iPhone 已与“%@”配对。" + } + } + } + }, + "pairing.paired.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paired" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已配对" + } + } + } + }, + "pairing.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pairing Code" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配对码" + } + } + } + }, + "scanner.camera.denied": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Camera access is off for Ask Key. Allow it in Settings to scan the QR code." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请旨的相机权限已关闭。请在“设置”中允许,才能扫描二维码。" + } + } + } + }, + "scanner.camera.notDetermined": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask Key needs the camera to scan the QR code." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请旨需要用相机扫描二维码。" + } + } + } + }, + "scanner.camera.restricted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Camera access is restricted on this iPhone." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这台 iPhone 的相机访问受到限制。" + } + } + } + }, + "scanner.camera.unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No camera is available on this device." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这台设备没有可用的相机。" + } + } + } + }, + "scanner.instruction": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan only the QR code that Ask Key shows on your own Mac. Never scan a pairing code that someone sends you." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "只扫描你自己的 Mac 上请旨显示的二维码。别人发给你的配对码,一律不要扫。" + } + } + } + }, + "scanner.openSettings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Settings" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开“设置”" + } + } + } + }, + "scanner.problem.deviceLimitReached": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This iPhone is already paired with 8 Macs. Remove one before pairing another." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这台 iPhone 已与 8 台 Mac 配对。请先移除一台,再配对新的。" + } + } + } + }, + "scanner.problem.expired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This pairing code has expired. On your Mac, start pairing again and scan the new code." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这个配对码已过期。请在 Mac 上重新开始配对,然后扫描新的二维码。" + } + } + } + }, + "scanner.problem.failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pairing couldn't start. Try again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法开始配对。请重试。" + } + } + } + }, + "scanner.problem.invalid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This pairing code can't be read. On your Mac, start pairing again and scan the new code." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法读取这个配对码。请在 Mac 上重新开始配对,然后扫描新的二维码。" + } + } + } + }, + "scanner.problem.keyConflict": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This pairing code can't be used with this iPhone." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这个配对码不能用于这台 iPhone。" + } + } + } + }, + "scanner.problem.notAPairingCode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This isn't an Ask Key pairing code. Scan the QR code in Ask Key on your own Mac." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这不是请旨的配对码。请扫描你自己的 Mac 上请旨显示的二维码。" + } + } + } + }, + "scanner.scanAgain": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan Again" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新扫描" + } + } + } + }, + "scanner.sending": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sending the pairing request…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在发送配对请求…" + } + } + } + }, + "scanner.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan the QR Code" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扫描二维码" + } + } + } + }, + "unusable.confirmTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete this iPhone's keys and its paired Macs?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除这台 iPhone 的密钥和已配对的 Mac?" + } + } + } + }, + "unusable.consequence": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset deletes this iPhone's keys and its list of paired Macs. Then remove this iPhone in Ask Key on each Mac and pair again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置会删除这台 iPhone 的密钥和已配对 Mac 的列表。之后请在每台 Mac 上的请旨中移除这台 iPhone,再重新配对。" + } + } + } + }, + "unusable.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask Key can't use this iPhone's keys anymore. This happens after Face ID or Touch ID changes, such as adding a new appearance or finger." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请旨无法再使用这台 iPhone 的密钥。更改面容 ID 或触控 ID(例如添加新的外貌或指纹)后会出现这种情况。" + } + } + } + }, + "unusable.failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The keys couldn't be deleted. Try again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法删除密钥。请重试。" + } + } + } + }, + "unusable.reset": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + } + } + }, + "unusable.resetting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resetting…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在重置…" + } + } + } + }, + "unusable.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This iPhone's Keys No Longer Work" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这台 iPhone 的密钥已失效" + } + } + } + }, + "welcome.checking": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在检查…" + } + } + } + }, + "welcome.continue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "继续" + } + } + } + }, + "welcome.failure.biometryDenied": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Face ID is turned off for Ask Key. Turn it on in Settings → Ask Key, then try again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请旨的面容 ID 权限已关闭。请在“设置”→“请旨”中打开,然后重试。" + } + } + } + }, + "welcome.failure.cloudNotConfigured": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This build of Ask Key isn't set up for iCloud, so it can't pair." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这个版本的请旨没有配置 iCloud,因此不能配对。" + } + } + } + }, + "welcome.failure.faceIDNotEnrolled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Set up Face ID in Settings first. Ask Key confirms every pairing with Face ID." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请先在“设置”中设置面容 ID。请旨每次配对都要用面容 ID 确认。" + } + } + } + }, + "welcome.failure.iCloudFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "iCloud didn't finish setting up Ask Key's inbox. Try again in a moment." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "iCloud 没能完成请旨收件箱的设置。请稍后重试。" + } + } + } + }, + "welcome.failure.iCloudRestricted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "iCloud is restricted on this iPhone, for example by Screen Time or a device management profile." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这台 iPhone 的 iCloud 受到限制,例如被“屏幕使用时间”或设备管理描述文件限制。" + } + } + } + }, + "welcome.failure.iCloudSignedOut": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in to iCloud in Settings with the same Apple Account as your Mac, then try again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请在“设置”中用与 Mac 相同的 Apple 账户登录 iCloud,然后重试。" + } + } + } + }, + "welcome.failure.iCloudUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "iCloud isn't available right now. Check your Apple Account in Settings, then try again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "iCloud 暂时不可用。请在“设置”中检查你的 Apple 账户,然后重试。" + } + } + } + }, + "welcome.failure.keyCreationFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Secure Enclave didn't create the keys. Try again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安全隔区没有生成密钥。请重试。" + } + } + } + }, + "welcome.failure.keychainFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask Key couldn't read or save its keys on this iPhone. Try again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请旨无法在这台 iPhone 上读取或保存密钥。请重试。" + } + } + } + }, + "welcome.failure.networkUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask Key couldn't reach iCloud. Check the connection. If your iPhone asked about wireless data, allow WLAN & Cellular Data in Settings → Ask Key → Wireless Data, then try again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请旨无法连接 iCloud。请检查网络。如果 iPhone 询问过无线数据权限,请在“设置”→“请旨”→“无线数据”中选择“无线局域网与蜂窝网络”,然后重试。" + } + } + } + }, + "welcome.failure.noBiometry": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This iPhone has neither Face ID nor Touch ID, so it can't confirm a pairing." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这台 iPhone 没有面容 ID 或触控 ID,无法确认配对。" + } + } + } + }, + "welcome.failure.noSecureEnclave": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This iPhone has no Secure Enclave, so it can't keep Ask Key's keys safe and can't pair." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这台 iPhone 没有安全隔区,无法安全地保存请旨的密钥,因此不能配对。" + } + } + } + }, + "welcome.failure.passcodeNotSet": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Set a passcode and Face ID or Touch ID in Settings first." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请先在“设置”中设置密码以及面容 ID 或触控 ID。" + } + } + } + }, + "welcome.failure.touchIDNotEnrolled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Set up Touch ID in Settings first. Ask Key confirms every pairing with Touch ID." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请先在“设置”中设置触控 ID。请旨每次配对都要用触控 ID 确认。" + } + } + } + }, + "welcome.requirement.biometry": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You confirm every pairing with Face ID or Touch ID. The passcode alone is not enough." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每次配对都要用面容 ID 或触控 ID 确认,只用密码不行。" + } + } + } + }, + "welcome.requirement.icloud": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your Mac and this iPhone exchange encrypted messages through iCloud, so both must use the same Apple Account." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Mac 与这台 iPhone 通过 iCloud 交换加密消息,所以两台设备必须登录同一个 Apple 账户。" + } + } + } + }, + "welcome.requirement.notifications": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask Key asks to send notifications, so you see when your Mac checks that it can reach this iPhone." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请旨会请求发送通知,这样 Mac 检查能否联系到这台 iPhone 时,你能看到提示。" + } + } + } + }, + "welcome.requirement.secureEnclave": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This iPhone's keys are created in its Secure Enclave and never leave it." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这台 iPhone 的密钥在它的安全隔区中生成,永远不会离开它。" + } + } + } + }, + "welcome.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask Key on this iPhone pairs with Ask Key on your Mac. First it checks that this iPhone can keep the pairing safe." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "iPhone 上的请旨会与 Mac 上的请旨配对。开始前,它会先检查这台 iPhone 能否安全地保存配对。" + } + } + } + }, + "welcome.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pair This iPhone with Your Mac" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将这台 iPhone 与你的 Mac 配对" + } + } + } + }, + "welcome.tryAgain": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try Again" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重试" + } + } + } + }, + "welcome.wirelessData": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Some iPhones ask whether Ask Key may use wireless data the first time it goes online. Choose WLAN & Cellular Data, or Ask Key can't reach iCloud." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "部分 iPhone 会在请旨首次联网时询问是否允许使用无线数据。请选择“无线局域网与蜂窝网络”,否则请旨无法连接 iCloud。" + } + } + } + } + }, + "version": "1.0" +} diff --git a/iOS/AskKeyiOSKit/Services/AppEnvironment.swift b/iOS/AskKeyiOSKit/Services/AppEnvironment.swift new file mode 100644 index 0000000..a5675f5 --- /dev/null +++ b/iOS/AskKeyiOSKit/Services/AppEnvironment.swift @@ -0,0 +1,114 @@ +import AskKeyDevices +import Foundation + +/// Face ID or Touch ID, as the welcome screen checks it. +public enum BiometryKind: Hashable, Sendable { + case faceID + case touchID +} + +public enum BiometryStatus: Hashable, Sendable { + case enrolled(BiometryKind) + case notEnrolled(BiometryKind) + /// The iPhone has no passcode, so Face ID or Touch ID cannot be set up. + case passcodeNotSet + /// The person turned Face ID or Touch ID off for Ask Key in Settings. + case deniedForApp(BiometryKind) + /// This iPhone has neither Face ID nor Touch ID. + case unavailable +} + +public protocol BiometryChecking: Sendable { + func status() -> BiometryStatus +} + +public enum CameraAccess: Hashable, Sendable { + case authorized + case notDetermined + case denied + case restricted + /// No camera, as in the iOS Simulator. + case unavailable +} + +public protocol CameraAccessChecking: Sendable { + func access() -> CameraAccess + /// Shows the system's camera prompt once; returns whether access was granted. + func requestAccess() async -> Bool +} + +/// The system notification center as the app uses it. A push is only a +/// hint to fetch the inbox: nothing here acts on a notification's text, +/// category or action. +public protocol NotificationCenterAccess: Sendable { + /// Asks for alert and sound permission and registers for remote notifications. + func requestAuthorizationAndRegister() async + /// At a later launch: registers again, and asks only if the person was never asked. + func registerAtLaunch() async + /// The pushes for this device's own inbox that are still in Notification Center. + func deliveredPushHints(for device: DeviceID) async -> [PushHint] + func removeDeliveredNotifications(identifiers: [String]) +} + +/// Creates and restores the device identity with the iPhone's key protection. +public protocol DeviceIdentityFactory: Sendable { + var isSecureEnclaveAvailable: Bool { get } + /// Creates three new keys and returns them with their blobs. Throws `DeviceIdentityError`. + func create() throws -> (identity: any DeviceIdentity, blobs: DeviceIdentityKeyBlobs) + /// Throws `DeviceIdentityError`. + func restore(_ blobs: DeviceIdentityKeyBlobs) throws -> any DeviceIdentity +} + +/// The inbox transport with the account and inbox calls of the CloudKit transport. +public protocol InboxTransport: DeviceTransport { + func accountState(storedUserRecordName: String?) async -> CloudKitAccountState + func prepareInbox() async throws -> InboxPreparation + func removeInbox() async throws +} + +extension CloudKitDeviceTransport: InboxTransport {} + +/// Creates the transport. Creating a CloudKit container terminates an app +/// whose signed entitlements lack it, so the app asks for a transport only +/// after the welcome checks passed (or an identity created after them +/// exists) and only when `isConfigured` is true. +public protocol InboxTransportFactory: Sendable { + /// Whether this build names a CloudKit container in its Info.plist. Only + /// the Ad Hoc export of `scripts/build-ios.sh` does, together with the + /// iCloud entitlement. + var isConfigured: Bool { get } + /// Nil when the build is not configured. + func makeTransport(for device: DeviceID) -> (any InboxTransport)? + /// Yields whenever the system reports an iCloud account change. + func accountChanges() -> AsyncStream +} + +/// Everything the app reaches outside its own memory. `live()` is the only +/// production wiring; tests pass fakes. +public struct AppEnvironment { + public var identities: any DeviceIdentityFactory + public var keychain: any SecureItemStore + public var localState: any LocalStateStore + public var biometry: any BiometryChecking + public var camera: any CameraAccessChecking + public var notifications: any NotificationCenterAccess + public var cloud: any InboxTransportFactory + public var clock: any DeviceClock + /// The name proposed to the Mac, which can rename the device. + public var deviceName: @MainActor () -> String + + public init(identities: any DeviceIdentityFactory, keychain: any SecureItemStore, localState: any LocalStateStore, + biometry: any BiometryChecking, camera: any CameraAccessChecking, + notifications: any NotificationCenterAccess, cloud: any InboxTransportFactory, + clock: any DeviceClock, deviceName: @escaping @MainActor () -> String) { + self.identities = identities + self.keychain = keychain + self.localState = localState + self.biometry = biometry + self.camera = camera + self.notifications = notifications + self.cloud = cloud + self.clock = clock + self.deviceName = deviceName + } +} diff --git a/iOS/AskKeyiOSKit/Services/DeviceKeychain.swift b/iOS/AskKeyiOSKit/Services/DeviceKeychain.swift new file mode 100644 index 0000000..5e212a6 --- /dev/null +++ b/iOS/AskKeyiOSKit/Services/DeviceKeychain.swift @@ -0,0 +1,146 @@ +import AskKeyDevices +import Foundation +import Security + +/// Items in one keychain service, by account. +public protocol SecureItemStore: Sendable { + func data(for account: String) throws -> Data? + func setData(_ data: Data, for account: String) throws + /// Deleting an item that does not exist succeeds. + func deleteData(for account: String) throws +} + +public struct KeychainError: Error, Equatable, Sendable { + public let status: OSStatus +} + +/// The iPhone's data protection keychain (ADR 0013 → Device identity and +/// Paired-device list): every item is accessible after first unlock, on +/// this device only, never synchronized, in the shared access group the +/// stage 4 notification extension will also read. +public final class DataProtectionKeychain: SecureItemStore, @unchecked Sendable { + public static let service = "com.sudohg.askkey.ios.devices" + private let accessGroup: String? + + /// `accessGroup` is `$(AppIdentifierPrefix)com.sudohg.askkey.ios.shared`, + /// read from the Info.plist key `KeychainAccessGroup`. + public init(accessGroup: String?) { + self.accessGroup = accessGroup + } + + public convenience init(bundle: Bundle = .main) { + let group = (bundle.object(forInfoDictionaryKey: "KeychainAccessGroup") as? String) + .flatMap { $0.isEmpty ? nil : $0 } + self.init(accessGroup: group) + } + + private func query(_ account: String) -> [CFString: Any] { + var query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: Self.service, + kSecAttrAccount: account, + kSecUseDataProtectionKeychain: true, + kSecAttrSynchronizable: kCFBooleanFalse as Any, + ] + if let accessGroup { query[kSecAttrAccessGroup] = accessGroup } + return query + } + + public func data(for account: String) throws -> Data? { + var request = query(account) + request[kSecReturnData] = true + request[kSecMatchLimit] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(request as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { throw KeychainError(status: status) } + return data + } + + public func setData(_ data: Data, for account: String) throws { + let attributes: [CFString: Any] = [ + kSecValueData: data, + kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let status = SecItemUpdate(query(account) as CFDictionary, attributes as CFDictionary) + if status == errSecSuccess { return } + guard status == errSecItemNotFound else { throw KeychainError(status: status) } + let added = SecItemAdd(query(account).merging(attributes) { $1 } as CFDictionary, nil) + guard added == errSecSuccess else { throw KeychainError(status: added) } + } + + public func deleteData(for account: String) throws { + let status = SecItemDelete(query(account) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { throw KeychainError(status: status) } + } +} + +/// The keychain accounts of the iPhone's identity and list, the same names +/// the Mac uses (ADR 0013 → Identifiers). +enum DeviceKeychainAccount { + static let messageKey = "device-message-key" + static let agreementKey = "device-agreement-key" + static let approvalKey = "device-approval-key" + static let pairedDevices = "paired-devices" + static let all = [messageKey, agreementKey, approvalKey, pairedDevices] +} + +enum StoredIdentityError: Error, Equatable { + /// Some but not all of the three key blobs are stored. + case incomplete +} + +/// The three key blobs in the keychain. +struct IdentityKeychain { + let store: any SecureItemStore + + /// Nil when no key is stored; throws `StoredIdentityError.incomplete` + /// when only some are, and the keychain's error when it cannot be read. + func loadBlobs() throws -> DeviceIdentityKeyBlobs? { + let message = try store.data(for: DeviceKeychainAccount.messageKey) + let agreement = try store.data(for: DeviceKeychainAccount.agreementKey) + let approval = try store.data(for: DeviceKeychainAccount.approvalKey) + if message == nil, agreement == nil, approval == nil { return nil } + guard let message, let agreement, let approval else { throw StoredIdentityError.incomplete } + return DeviceIdentityKeyBlobs(message: message, agreement: agreement, approval: approval) + } + + func save(_ blobs: DeviceIdentityKeyBlobs) throws { + try store.setData(blobs.message, for: DeviceKeychainAccount.messageKey) + try store.setData(blobs.agreement, for: DeviceKeychainAccount.agreementKey) + try store.setData(blobs.approval, for: DeviceKeychainAccount.approvalKey) + } + + /// Deletes the keys and the paired-device list; every deletion is tried. + func deleteEverything() throws { + var firstError: Error? + for account in DeviceKeychainAccount.all { + do { + try store.deleteData(for: account) + } catch { + firstError = firstError ?? error + } + } + if let firstError { throw firstError } + } +} + +/// The paired-device list as one keychain item. Only the app process writes +/// it (ADR 0013 → Paired-device list); a stored list that does not decode +/// is refused, not repaired. +final class PairedDeviceListKeychainStore: PairedDeviceListStore, @unchecked Sendable { + private let store: any SecureItemStore + + init(store: any SecureItemStore) { + self.store = store + } + + func loadPairedDeviceList() throws -> PairedDeviceList { + guard let data = try store.data(for: DeviceKeychainAccount.pairedDevices) else { return PairedDeviceList() } + return try PairedDeviceList(serialized: data) + } + + func savePairedDeviceList(_ list: PairedDeviceList) throws { + try store.setData(try list.serialized(), for: DeviceKeychainAccount.pairedDevices) + } +} diff --git a/iOS/AskKeyiOSKit/Services/DeviceServices.swift b/iOS/AskKeyiOSKit/Services/DeviceServices.swift new file mode 100644 index 0000000..378883b --- /dev/null +++ b/iOS/AskKeyiOSKit/Services/DeviceServices.swift @@ -0,0 +1,82 @@ +import AskKeyDevices +import Foundation + +/// Passes every call to the transport and remembers the plaintext kind of +/// each fetched record, so the app can tell which processed messages were +/// delivery checks. A processed message's plaintext kind equals its signed +/// kind; the messenger refuses any other. +final class KindRecordingTransport: InboxTransport, @unchecked Sendable { + let base: any InboxTransport + private let lock = NSLock() + private var kinds: [String: Int64] = [:] + + init(base: any InboxTransport) { + self.base = base + } + + /// The kinds recorded since the last call. + func takeKinds() -> [String: Int64] { + lock.withLock { + defer { kinds = [:] } + return kinds + } + } + + func send(_ envelope: DeviceEnvelope) async throws { + try await base.send(envelope) + } + + func fetchInboxChanges(of device: DeviceID, since token: InboxChangeToken?) async throws -> InboxChanges { + let changes = try await base.fetchInboxChanges(of: device, since: token) + lock.withLock { + for envelope in changes.envelopes { kinds[envelope.recordName] = envelope.kind } + } + return changes + } + + func deleteMessages(_ recordNames: [String], from device: DeviceID) async throws { + try await base.deleteMessages(recordNames, from: device) + } + + func accountState(storedUserRecordName: String?) async -> CloudKitAccountState { + await base.accountState(storedUserRecordName: storedUserRecordName) + } + + func prepareInbox() async throws -> InboxPreparation { + try await base.prepareInbox() + } + + func removeInbox() async throws { + try await base.removeInbox() + } +} + +/// One identity's running services: the messenger over the transport and +/// the keychain list. The app drops it before deleting anything, so no +/// work that started earlier can write afterwards. +final class DeviceServices { + let identity: any DeviceIdentity + let deviceID: DeviceID + let transport: KindRecordingTransport + let registry: PairedDeviceRegistry + private(set) var messenger: DeviceMessenger + private let clock: any DeviceClock + + init(identity: any DeviceIdentity, transport: any InboxTransport, listStore: any PairedDeviceListStore, + clock: any DeviceClock, inboxToken: InboxChangeToken?) { + self.identity = identity + self.clock = clock + deviceID = DeviceID(messagePublicKey: identity.messagePublicKey) + self.transport = KindRecordingTransport(base: transport) + registry = PairedDeviceRegistry(store: listStore) + messenger = DeviceMessenger(identity: identity, platform: .iOS, registry: registry, + transport: self.transport, clock: clock, inboxToken: inboxToken) + } + + /// After an account change: the old account's token and in-memory + /// sessions are dropped; the list is kept. + func restartMessenger() { + messenger = DeviceMessenger(identity: identity, platform: .iOS, registry: registry, transport: transport, + clock: clock, inboxToken: nil) + } +} diff --git a/iOS/AskKeyiOSKit/Services/LiveServices.swift b/iOS/AskKeyiOSKit/Services/LiveServices.swift new file mode 100644 index 0000000..c2c9af0 --- /dev/null +++ b/iOS/AskKeyiOSKit/Services/LiveServices.swift @@ -0,0 +1,151 @@ +import AskKeyDevices +import AVFoundation +import CloudKit +import Foundation +import LocalAuthentication +import UIKit +import UserNotifications + +/// The iPhone's key protection (ADR 0013 → Key access control): the message +/// and key-agreement keys work after first unlock without a prompt, so a +/// delivery reply or an unpair notice never asks for Face ID; the approval +/// key needs the device unlocked and the currently enrolled Face ID or Touch +/// ID for every signature, with no passcode fallback. +enum IPhoneKeyProtection { + static let protections = DeviceKeyProtections( + message: DeviceKeyProtection(accessibility: .afterFirstUnlockThisDeviceOnly, authentication: .none), + agreement: DeviceKeyProtection(accessibility: .afterFirstUnlockThisDeviceOnly, authentication: .none), + approval: DeviceKeyProtection(accessibility: .whenUnlockedThisDeviceOnly, authentication: .biometryCurrentSet)) +} + +struct SecureEnclaveIdentityFactory: DeviceIdentityFactory { + var isSecureEnclaveAvailable: Bool { SecureEnclaveDeviceIdentity.isAvailable } + + func create() throws -> (identity: any DeviceIdentity, blobs: DeviceIdentityKeyBlobs) { + let identity = try SecureEnclaveDeviceIdentity.create(protections: IPhoneKeyProtection.protections) + return (identity, identity.keyBlobs) + } + + func restore(_ blobs: DeviceIdentityKeyBlobs) throws -> any DeviceIdentity { + try SecureEnclaveDeviceIdentity.restore(from: blobs) + } +} + +struct LocalAuthenticationBiometry: BiometryChecking { + func status() -> BiometryStatus { + let context = LAContext() + defer { context.invalidate() } + var error: NSError? + let canEvaluate = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) + let kind: BiometryKind = context.biometryType == .touchID ? .touchID : .faceID + if canEvaluate { return .enrolled(kind) } + switch error.flatMap({ LAError.Code(rawValue: $0.code) }) { + case .biometryNotEnrolled: return .notEnrolled(kind) + case .passcodeNotSet: return .passcodeNotSet + // Locked out after failed attempts: still enrolled; the prompt unlocks it. + case .biometryLockout: return .enrolled(kind) + case .biometryNotAvailable: return context.biometryType == .none ? .unavailable : .deniedForApp(kind) + default: return .unavailable + } + } +} + +struct AVCameraAccess: CameraAccessChecking { + func access() -> CameraAccess { + guard AVCaptureDevice.default(for: .video) != nil else { return .unavailable } + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: return .authorized + case .notDetermined: return .notDetermined + case .restricted: return .restricted + default: return .denied + } + } + + func requestAccess() async -> Bool { + await AVCaptureDevice.requestAccess(for: .video) + } +} + +struct UserNotificationCenterAccess: NotificationCenterAccess { + func requestAuthorizationAndRegister() async { + do { + _ = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) + } catch { + AppLog.failure("Notification authorization", error) + } + await MainActor.run { UIApplication.shared.registerForRemoteNotifications() } + } + + func registerAtLaunch() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + if settings.authorizationStatus == .notDetermined { + await requestAuthorizationAndRegister() + } else { + await MainActor.run { UIApplication.shared.registerForRemoteNotifications() } + } + } + + func deliveredPushHints(for device: DeviceID) async -> [PushHint] { + let delivered = await UNUserNotificationCenter.current().deliveredNotifications() + return delivered.compactMap { notification in + PushHintReader.hint(userInfo: notification.request.content.userInfo, arrivedAt: notification.date, + notificationIdentifier: notification.request.identifier, for: device) + } + } + + func removeDeliveredNotifications(identifiers: [String]) { + guard !identifiers.isEmpty else { return } + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) + } +} + +/// Reads the container from the Info.plist key `CloudKitContainerIdentifier` +/// (ADR 0013 → Identifiers). Only the Ad Hoc export sets it, and only that +/// build carries the iCloud entitlement; every other build has it empty and +/// never creates a container. +public struct CloudKitTransportFactory: InboxTransportFactory { + let containerIdentifier: String? + + public init(bundle: Bundle = .main) { + containerIdentifier = (bundle.object(forInfoDictionaryKey: "CloudKitContainerIdentifier") as? String) + .flatMap { $0.isEmpty ? nil : $0 } + } + + public var isConfigured: Bool { containerIdentifier != nil } + + public func makeTransport(for device: DeviceID) -> (any InboxTransport)? { + guard let containerIdentifier else { return nil } + return CloudKitDeviceTransport(containerIdentifier: containerIdentifier, deviceID: device, platform: .iOS) + } + + public func accountChanges() -> AsyncStream { + CloudKitDeviceTransport.accountChanges() + } +} + +/// Turns a received notification into a hint, only when it is a CloudKit +/// query notification for this device's own inbox (ADR 0013 → CloudKit +/// layout); subscriptions belong to the account, so other devices' pushes +/// arrive here too. +enum PushHintReader { + static func hint(userInfo: [AnyHashable: Any], arrivedAt: Date, notificationIdentifier: String?, + for device: DeviceID) -> PushHint? { + guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo), + CloudKitInboxLayout.isPush(notification, for: device), + let recordName = (notification as? CKQueryNotification)?.recordID?.recordName else { + return nil + } + return PushHint(recordName: recordName, arrivedAt: arrivedAt, notificationIdentifier: notificationIdentifier) + } +} + +extension AppEnvironment { + /// The production wiring. Nothing here creates a CloudKit container. + public static func live() -> AppEnvironment { + AppEnvironment(identities: SecureEnclaveIdentityFactory(), keychain: DataProtectionKeychain(), + localState: UserDefaultsLocalState(), biometry: LocalAuthenticationBiometry(), + camera: AVCameraAccess(), notifications: UserNotificationCenterAccess(), + cloud: CloudKitTransportFactory(), clock: SystemDeviceClock(), + deviceName: { UIDevice.current.name }) + } +} diff --git a/iOS/AskKeyiOSKit/Services/LocalState.swift b/iOS/AskKeyiOSKit/Services/LocalState.swift new file mode 100644 index 0000000..be2ac83 --- /dev/null +++ b/iOS/AskKeyiOSKit/Services/LocalState.swift @@ -0,0 +1,141 @@ +import AskKeyDevices +import Foundation +import os + +/// The inbox position: the change token after the last fully processed +/// fetch, stored with the iCloud account it belongs to (ADR 0013 → Push is a hint). +public struct InboxPosition: Codable, Hashable, Sendable { + public var userRecordName: String + public var token: Data? + + public init(userRecordName: String, token: Data?) { + self.userRecordName = userRecordName + self.token = token + } +} + +/// One delivery check this iPhone answered, without content: when it +/// arrived, its record name, and whether the app found it through a push +/// or by polling its inbox, so a test can tell push delivery from an opened +/// app (ADR 0013 → Pairing, step 9). +public struct DeliveryRecord: Codable, Hashable, Sendable { + public enum Source: String, Codable, Sendable { + case push + case polling + } + + public let recordName: String + /// The notification's delivery time for a push; the fetch time otherwise. + public let arrivedAt: Date + public let source: Source + + public init(recordName: String, arrivedAt: Date, source: Source) { + self.recordName = recordName + self.arrivedAt = arrivedAt + self.source = source + } +} + +/// A push for this device's own inbox. It only says that something may be +/// waiting; the app fetches and verifies the inbox itself. +public struct PushHint: Hashable, Sendable { + public let recordName: String + public let arrivedAt: Date + /// The delivered notification's request identifier, if it is in Notification Center. + public let notificationIdentifier: String? + + public init(recordName: String, arrivedAt: Date, notificationIdentifier: String?) { + self.recordName = recordName + self.arrivedAt = arrivedAt + self.notificationIdentifier = notificationIdentifier + } +} + +/// The app's local state outside the keychain. None of it is secret. +public protocol LocalStateStore: AnyObject { + var inboxPosition: InboxPosition? { get set } + /// This device's ID, kept so a reset can delete the inbox even when the + /// keys no longer restore. + var deviceID: String? { get set } + var deliveryRecords: [DeliveryRecord] { get set } + func removeAll() +} + +public final class UserDefaultsLocalState: LocalStateStore { + private enum Key { + static let inboxPosition = "inboxPosition" + static let deviceID = "deviceID" + static let deliveryRecords = "deliveryRecords" + } + + private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + public var inboxPosition: InboxPosition? { + get { decode(InboxPosition.self, Key.inboxPosition) } + set { encode(newValue, Key.inboxPosition) } + } + + public var deviceID: String? { + get { defaults.string(forKey: Key.deviceID) } + set { defaults.set(newValue, forKey: Key.deviceID) } + } + + public var deliveryRecords: [DeliveryRecord] { + get { decode([DeliveryRecord].self, Key.deliveryRecords) ?? [] } + set { encode(newValue, Key.deliveryRecords) } + } + + public func removeAll() { + for key in [Key.inboxPosition, Key.deviceID, Key.deliveryRecords] { defaults.removeObject(forKey: key) } + } + + private func decode(_ type: Value.Type, _ key: String) -> Value? { + defaults.data(forKey: key).flatMap { try? JSONDecoder().decode(type, from: $0) } + } + + private func encode(_ value: Value?, _ key: String) { + guard let value, let data = try? JSONEncoder().encode(value) else { + defaults.removeObject(forKey: key) + return + } + defaults.set(data, forKey: key) + } +} + +/// The unified log, without content: no code, no name, no payload. Record +/// names, times and refusal reasons are not secret. +enum AppLog { + private static let logger = Logger(subsystem: "com.sudohg.askkey.ios", category: "Devices") + + static func delivery(_ record: DeliveryRecord) { + logger.notice("Delivery check \(record.recordName, privacy: .public) arrived at \(record.arrivedAt.ISO8601Format(), privacy: .public), found by \(record.source.rawValue, privacy: .public)") + } + + static func refusals(_ refusals: [DeviceMessageRefusal]) { + for refusal in refusals { + logger.notice("Refused a message: \(String(describing: refusal), privacy: .public)") + } + } + + static func failure(_ operation: StaticString, _ error: Error) { + logger.error("\(operation, privacy: .public) failed: \(String(describing: type(of: error)), privacy: .public) \(Self.code(error), privacy: .public)") + } + + /// The error's case for the module's own errors, and the domain and + /// code for system errors; never a message that could carry content. + private static func code(_ error: Error) -> String { + switch error { + case let error as DeviceTransportError: return String(describing: error) + case let error as DeviceIdentityError: return String(describing: error) + case let error as DeviceMessengerError: return String(describing: error) + case let error as KeychainError: return "status \(error.status)" + default: + let nsError = error as NSError + return "\(nsError.domain) \(nsError.code)" + } + } +} diff --git a/iOS/AskKeyiOSKit/Views/Components.swift b/iOS/AskKeyiOSKit/Views/Components.swift new file mode 100644 index 0000000..da7f7fe --- /dev/null +++ b/iOS/AskKeyiOSKit/Views/Components.swift @@ -0,0 +1,120 @@ +import SwiftUI + +private final class KitBundleToken {} + +extension Bundle { + /// The framework's bundle, which holds the String Catalog. + static let kit = Bundle(for: KitBundleToken.self) +} + +/// One screen's content: a title, an optional subtitle and a column of +/// sections. The content is drawn by SwiftUI alone, so the same view that +/// the app shows can be rendered for review. +struct ScreenContent: View { + let title: LocalizedStringKey + var subtitle: LocalizedStringKey? + @ViewBuilder var content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 8) { + Text(title, bundle: .kit) + .font(.largeTitle.bold()) + .fixedSize(horizontal: false, vertical: true) + if let subtitle { + Text(subtitle, bundle: .kit) + .font(.body) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + content + } + .padding(.horizontal, 20) + .padding(.vertical, 24) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +/// A rounded block of text with a symbol, for instructions and outcomes. +struct NoticeCard: View { + enum Tone { + case info + case warning + case success + } + + let symbol: String + var tone: Tone = .info + let text: Text + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: symbol) + .font(.title3) + .foregroundStyle(color) + .frame(width: 28) + text + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + .padding(14) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(color.opacity(0.1))) + } + + private var color: Color { + switch tone { + case .info: return .accentColor + case .warning: return .orange + case .success: return .green + } + } +} + +/// A full-width button drawn by SwiftUI. +struct ActionButtonStyle: ButtonStyle { + enum Kind { + case primary + case secondary + case destructive + } + + var kind: Kind = .primary + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.headline) + .frame(maxWidth: .infinity, minHeight: 50) + .foregroundStyle(foreground) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(background)) + .opacity(configuration.isPressed ? 0.7 : (isEnabled ? 1 : 0.45)) + } + + private var foreground: Color { + switch kind { + case .primary: return .white + case .secondary: return .accentColor + case .destructive: return .red + } + } + + private var background: Color { + switch kind { + case .primary: return .accentColor + case .secondary: return Color.accentColor.opacity(0.12) + case .destructive: return Color.red.opacity(0.12) + } + } +} + +extension View { + /// Wraps a screen's content for the app: scrollable on a grouped background. + func screenScaffold() -> some View { + ScrollView { + self + } + .background(Color(uiColor: .systemGroupedBackground).ignoresSafeArea()) + } +} diff --git a/iOS/AskKeyiOSKit/Views/PairedMacsView.swift b/iOS/AskKeyiOSKit/Views/PairedMacsView.swift new file mode 100644 index 0000000..f0be1a0 --- /dev/null +++ b/iOS/AskKeyiOSKit/Views/PairedMacsView.swift @@ -0,0 +1,150 @@ +import SwiftUI + +struct PairedMacsView: View { + @Bindable var model: AppModel + @State private var pendingRemoval: PairedMacRow? + + var body: some View { + PairedMacsContent(rows: model.pairedMacs, accountNotice: model.accountNotice, listNotice: model.listNotice, + records: model.deliveryRecords, onPair: model.startPairing, + onRemove: { pendingRemoval = $0 }, onDismissNotice: { model.listNotice = nil }) + .screenScaffold() + .refreshable { await model.processInbox() } + .confirmationDialog(Text("list.remove.confirmTitle \(pendingRemoval?.name ?? String())", bundle: .kit), + isPresented: Binding(get: { pendingRemoval != nil }, set: { if !$0 { pendingRemoval = nil } }), + titleVisibility: .visible, presenting: pendingRemoval) { row in + Button(role: .destructive) { + Task { await model.removeMac(id: row.id) } + } label: { + Text("list.remove.confirm", bundle: .kit) + } + Button(role: .cancel) {} label: { Text("common.cancel", bundle: .kit) } + } message: { _ in + Text("list.remove.confirmMessage", bundle: .kit) + } + } +} + +/// The paired Macs: name, paired date and last reached, with Remove; the +/// iCloud state and the outcome of the last removal; and the delivery +/// checks this iPhone answered. +struct PairedMacsContent: View { + let rows: [PairedMacRow] + let accountNotice: AccountNotice? + let listNotice: ListNotice? + let records: [DeliveryRecord] + let onPair: () -> Void + let onRemove: (PairedMacRow) -> Void + let onDismissNotice: () -> Void + + var body: some View { + ScreenContent(title: "list.title") { + if let accountNotice { + NoticeCard(symbol: "icloud.slash", tone: .warning, text: Text(Self.explanation(accountNotice), bundle: .kit)) + } + if let listNotice { + HStack(alignment: .top) { + NoticeCard(symbol: "info.circle.fill", tone: listNotice == .removalFailed ? .warning : .info, + text: Self.text(listNotice)) + Button(action: onDismissNotice) { + Image(systemName: "xmark.circle.fill").font(.title3).foregroundStyle(.secondary) + } + .accessibilityLabel(Text("common.dismiss", bundle: .kit)) + } + } + if rows.isEmpty { + NoticeCard(symbol: "desktopcomputer", text: Text("list.empty", bundle: .kit)) + } else { + VStack(spacing: 12) { + ForEach(rows) { row in + MacRow(row: row) { onRemove(row) } + } + } + } + Button(action: onPair) { + Label { Text("list.pair", bundle: .kit) } icon: { Image(systemName: "qrcode.viewfinder") } + } + .buttonStyle(ActionButtonStyle()) + if !records.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("list.deliveries.title", bundle: .kit) + .font(.headline) + ForEach(Array(records.suffix(5).reversed()), id: \.self) { record in + Self.recordText(record) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } + + static func explanation(_ notice: AccountNotice) -> LocalizedStringKey { + switch notice { + case .signedOut: return "list.account.signedOut" + case .restricted: return "list.account.restricted" + case .unavailable: return "list.account.unavailable" + } + } + + static func text(_ notice: ListNotice) -> Text { + switch notice { + case .removed(let name, true): return Text("list.notice.removedAndTold \(name)", bundle: .kit) + case .removed(let name, false): return Text("list.notice.removedNotTold \(name)", bundle: .kit) + case .removedByMac(let name): return Text("list.notice.removedByMac \(name)", bundle: .kit) + case .removalFailed: return Text("list.notice.removalFailed", bundle: .kit) + } + } + + static func recordText(_ record: DeliveryRecord) -> Text { + let format = Date.FormatStyle.dateTime.month(.abbreviated).day().hour().minute().second() + switch record.source { + case .push: return Text("list.deliveries.push \(record.arrivedAt, format: format)", bundle: .kit) + case .polling: return Text("list.deliveries.polling \(record.arrivedAt, format: format)", bundle: .kit) + } + } +} + +private struct MacRow: View { + let row: PairedMacRow + let onRemove: () -> Void + + var body: some View { + HStack(alignment: .center, spacing: 14) { + Image(systemName: "laptopcomputer") + .font(.title2) + .foregroundStyle(Color.accentColor) + .frame(width: 32) + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: row.name) + .font(.headline) + Text("list.pairedOn \(row.pairedAt, format: .dateTime.year().month().day())", bundle: .kit) + .font(.footnote) + .foregroundStyle(.secondary) + if let reached = row.lastReachedAt { + Text("list.lastReached \(reached, format: .dateTime.year().month().day().hour().minute())", bundle: .kit) + .font(.footnote) + .foregroundStyle(.secondary) + } else { + Text("list.notReached", bundle: .kit) + .font(.footnote) + .foregroundStyle(.orange) + } + } + Spacer(minLength: 8) + Button(action: onRemove) { + Text("list.remove", bundle: .kit) + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Capsule().fill(Color.red.opacity(0.12))) + .foregroundStyle(.red) + } + .buttonStyle(.plain) + } + .padding(14) + .background(RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(uiColor: .secondarySystemGroupedBackground))) + } +} diff --git a/iOS/AskKeyiOSKit/Views/PairingFlowView.swift b/iOS/AskKeyiOSKit/Views/PairingFlowView.swift new file mode 100644 index 0000000..2f43601 --- /dev/null +++ b/iOS/AskKeyiOSKit/Views/PairingFlowView.swift @@ -0,0 +1,230 @@ +import SwiftUI +import UIKit + +/// The pairing sheet: scanner, pairing code, and the outcome. +struct PairingFlowView: View { + let model: PairingFlowModel + let onClose: () -> Void + + var body: some View { + content + .screenScaffold() + .interactiveDismissDisabled(model.isSessionOpen) + .task { await model.prepareCamera() } + .task { await model.run() } + } + + @ViewBuilder private var content: some View { + switch model.stage { + case .scanning, .sending: + ScannerContent(camera: model.camera, problem: model.scanProblem, isSending: model.stage == .sending, + onDismissProblem: model.dismissScanProblem, onCancel: onClose) { + QRScannerView { text in Task { await model.handleScanned(text) } } + } + case .waitingForCode: + PairingCodeContent(display: nil, isConfirming: false, problem: nil, onConfirm: {}, onCancel: onClose) + case .code(let display): + PairingCodeContent(display: display, isConfirming: model.isConfirming, problem: model.confirmProblem, + onConfirm: { Task { await model.confirm() } }, onCancel: onClose) + case .paired(let name): + PairingResultContent(outcome: .paired(macName: name), onDone: onClose) + case .failed(let failure): + PairingResultContent(outcome: .failed(failure), onDone: onClose) + } + } +} + +/// The QR scanner. It says to scan only the code in Ask Key on the +/// person's own Mac, never one someone sends. +struct ScannerContent: View { + let camera: CameraAccess + let problem: ScanProblem? + let isSending: Bool + let onDismissProblem: () -> Void + let onCancel: () -> Void + @ViewBuilder var preview: Preview + + var body: some View { + ScreenContent(title: "scanner.title") { + NoticeCard(symbol: "qrcode.viewfinder", tone: .warning, text: Text("scanner.instruction", bundle: .kit)) + Color.clear + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: .infinity) + .overlay { cameraArea } + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + if let problem { + VStack(alignment: .leading, spacing: 10) { + NoticeCard(symbol: "xmark.octagon.fill", tone: .warning, + text: Text(Self.explanation(problem), bundle: .kit)) + Button(action: onDismissProblem) { Text("scanner.scanAgain", bundle: .kit) } + .buttonStyle(ActionButtonStyle(kind: .secondary)) + } + } + Button(action: onCancel) { Text("common.cancel", bundle: .kit) } + .buttonStyle(ActionButtonStyle(kind: .secondary)) + } + } + + @ViewBuilder private var cameraArea: some View { + if camera == .authorized, !isSending { + preview + } else { + ZStack { + Color.black.opacity(0.85) + VStack(spacing: 12) { + Image(systemName: isSending ? "hourglass" : "camera.fill") + .font(.system(size: 40)) + Text(cameraMessage, bundle: .kit) + .font(.callout) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + if camera == .denied, !isSending { + Button { + if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } + } label: { + Text("scanner.openSettings", bundle: .kit).font(.callout.bold()) + } + } + } + .foregroundStyle(.white) + .padding(24) + } + } + } + + private var cameraMessage: LocalizedStringKey { + if isSending { return "scanner.sending" } + switch camera { + case .authorized, .notDetermined: return "scanner.camera.notDetermined" + case .denied: return "scanner.camera.denied" + case .restricted: return "scanner.camera.restricted" + case .unavailable: return "scanner.camera.unavailable" + } + } + + static func explanation(_ problem: ScanProblem) -> LocalizedStringKey { + switch problem { + case .notAPairingCode: return "scanner.problem.notAPairingCode" + case .invalid: return "scanner.problem.invalid" + case .expired: return "scanner.problem.expired" + case .keyConflict: return "scanner.problem.keyConflict" + case .deviceLimitReached: return "scanner.problem.deviceLimitReached" + case .failed: return "scanner.problem.failed" + } + } +} + +/// The pairing code to enter on the Mac. Before the challenge and the +/// reveal it says there is no code yet; in both states it says the code +/// appears only here and is never sent in a notification or a message. +struct PairingCodeContent: View { + let display: PairingCodeDisplay? + let isConfirming: Bool + let problem: ConfirmProblem? + let onConfirm: () -> Void + let onCancel: () -> Void + + var body: some View { + ScreenContent(title: "pairing.title") { + if let display { + VStack(alignment: .leading, spacing: 12) { + // The code is display text only: no identifier, never logged. + Text(verbatim: display.digits) + .font(.system(size: 60, weight: .semibold, design: .rounded).monospacedDigit()) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color(uiColor: .secondarySystemGroupedBackground))) + Text("pairing.code.instruction \(display.macName)", bundle: .kit) + .font(.headline) + .fixedSize(horizontal: false, vertical: true) + } + } else { + VStack(spacing: 12) { + Image(systemName: "hourglass") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + Text("pairing.noCode.title", bundle: .kit) + .font(.title2.bold()) + Text("pairing.noCode.detail", bundle: .kit) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + .background(RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color(uiColor: .secondarySystemGroupedBackground))) + } + NoticeCard(symbol: "lock.fill", text: Text("pairing.code.onlyHere", bundle: .kit)) + if let display { + if display.confirmed { + NoticeCard(symbol: "checkmark.circle.fill", tone: .success, text: Text("pairing.confirmed", bundle: .kit)) + } else { + if let problem { + NoticeCard(symbol: "exclamationmark.triangle.fill", tone: .warning, + text: Text(Self.explanation(problem), bundle: .kit)) + } + Button(action: onConfirm) { Text("pairing.confirm", bundle: .kit) } + .buttonStyle(ActionButtonStyle()) + .disabled(isConfirming) + Text("pairing.confirm.footnote", bundle: .kit) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + Button(action: onCancel) { Text("pairing.cancel", bundle: .kit) } + .buttonStyle(ActionButtonStyle(kind: .destructive)) + } + } + + static func explanation(_ problem: ConfirmProblem) -> LocalizedStringKey { + switch problem { + case .cancelled: return "pairing.confirm.cancelled" + case .failed: return "pairing.confirm.failed" + case .unavailable: return "pairing.confirm.unavailable" + } + } +} + +struct PairingResultContent: View { + enum Outcome: Hashable { + case paired(macName: String) + case failed(PairingFailure) + } + + let outcome: Outcome + let onDone: () -> Void + + var body: some View { + ScreenContent(title: title) { + switch outcome { + case .paired(let name): + NoticeCard(symbol: "checkmark.seal.fill", tone: .success, text: Text("pairing.paired.detail \(name)", bundle: .kit)) + case .failed(let failure): + NoticeCard(symbol: "xmark.octagon.fill", tone: .warning, text: Text(Self.explanation(failure), bundle: .kit)) + } + Button(action: onDone) { Text("common.done", bundle: .kit) } + .buttonStyle(ActionButtonStyle()) + } + } + + private var title: LocalizedStringKey { + if case .paired = outcome { return "pairing.paired.title" } + return "pairing.failed.title" + } + + static func explanation(_ failure: PairingFailure) -> LocalizedStringKey { + switch failure { + case .noChallenge: return "pairing.failed.noChallenge" + case .cancelledByMac: return "pairing.failed.cancelledByMac" + case .expired: return "pairing.failed.expired" + case .conflictingChallenge: return "pairing.failed.conflictingChallenge" + case .deviceLimitReached: return "pairing.failed.deviceLimitReached" + case .keyConflict: return "pairing.failed.keyConflict" + case .ended: return "pairing.failed.ended" + } + } +} diff --git a/iOS/AskKeyiOSKit/Views/QRScannerView.swift b/iOS/AskKeyiOSKit/Views/QRScannerView.swift new file mode 100644 index 0000000..fff1c0d --- /dev/null +++ b/iOS/AskKeyiOSKit/Views/QRScannerView.swift @@ -0,0 +1,75 @@ +import AVFoundation +import SwiftUI +import UIKit + +/// The camera preview. It reads QR codes only and hands each one's text to +/// `onCode`; the camera is used for nothing else. +struct QRScannerView: UIViewRepresentable { + let onCode: (String) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onCode: onCode) + } + + func makeUIView(context: Context) -> PreviewView { + let view = PreviewView() + view.previewLayer.session = context.coordinator.session + view.previewLayer.videoGravity = .resizeAspectFill + context.coordinator.start() + return view + } + + func updateUIView(_ uiView: PreviewView, context: Context) { + context.coordinator.onCode = onCode + } + + static func dismantleUIView(_ uiView: PreviewView, coordinator: Coordinator) { + coordinator.stop() + } + + final class PreviewView: UIView { + override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } + var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } + } + + final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { + let session = AVCaptureSession() + var onCode: (String) -> Void + private let queue = DispatchQueue(label: "com.sudohg.askkey.ios.camera") + + init(onCode: @escaping (String) -> Void) { + self.onCode = onCode + super.init() + configure() + } + + private func configure() { + guard let camera = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: camera), + session.canAddInput(input) else { return } + session.addInput(input) + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { return } + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = output.availableMetadataObjectTypes.contains(.qr) ? [.qr] : [] + } + + func start() { + let session = session + queue.async { if !session.isRunning { session.startRunning() } } + } + + func stop() { + let session = session + queue.async { if session.isRunning { session.stopRunning() } } + } + + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection) { + for case let code as AVMetadataMachineReadableCodeObject in metadataObjects where code.type == .qr { + if let text = code.stringValue { onCode(text) } + } + } + } +} diff --git a/iOS/AskKeyiOSKit/Views/RootView.swift b/iOS/AskKeyiOSKit/Views/RootView.swift new file mode 100644 index 0000000..7ff2e1f --- /dev/null +++ b/iOS/AskKeyiOSKit/Views/RootView.swift @@ -0,0 +1,77 @@ +import SwiftUI + +/// The app's only scene content. +public struct AskKeyRootView: View { + @Bindable var model: AppModel + @Environment(\.scenePhase) private var scenePhase + + public init(model: AppModel) { + self.model = model + } + + public var body: some View { + Group { + switch model.stage { + case .starting: + Color(uiColor: .systemGroupedBackground).ignoresSafeArea() + case .welcome: + WelcomeView(model: model) + case .ready: + PairedMacsView(model: model) + case .unusableIdentity: + UnusableIdentityView(model: model) + } + } + .sheet(item: $model.pairing, onDismiss: model.closePairing) { flow in + PairingFlowView(model: flow, onClose: model.closePairing) + } + .task { await model.start() } + .onChange(of: scenePhase) { _, phase in + if phase == .active { Task { await model.becameActive() } } + } + .onChange(of: model.stage) { _, stage in + if stage == .ready { Task { await model.becameActive() } } + } + } +} + +struct UnusableIdentityView: View { + let model: AppModel + @State private var confirming = false + + var body: some View { + UnusableIdentityContent(isResetting: model.isResetting, resetFailed: model.resetFailed) { confirming = true } + .screenScaffold() + .confirmationDialog(Text("unusable.confirmTitle", bundle: .kit), isPresented: $confirming, + titleVisibility: .visible) { + Button(role: .destructive) { + Task { await model.resetIdentity() } + } label: { + Text("unusable.reset", bundle: .kit) + } + Button(role: .cancel) {} label: { Text("common.cancel", bundle: .kit) } + } + } +} + +/// A stored key no longer works, for example after a Face ID or Touch ID +/// enrollment change; one reset deletes the keys and the list. +struct UnusableIdentityContent: View { + let isResetting: Bool + let resetFailed: Bool + let onReset: () -> Void + + var body: some View { + ScreenContent(title: "unusable.title", subtitle: "unusable.detail") { + NoticeCard(symbol: "arrow.counterclockwise", tone: .warning, text: Text("unusable.consequence", bundle: .kit)) + if resetFailed { + NoticeCard(symbol: "exclamationmark.triangle.fill", tone: .warning, text: Text("unusable.failed", bundle: .kit)) + } + Button(action: onReset) { + Text(isResetting ? "unusable.resetting" : "unusable.reset", bundle: .kit) + } + .buttonStyle(ActionButtonStyle(kind: .destructive)) + .disabled(isResetting) + } + } +} diff --git a/iOS/AskKeyiOSKit/Views/WelcomeView.swift b/iOS/AskKeyiOSKit/Views/WelcomeView.swift new file mode 100644 index 0000000..7f447eb --- /dev/null +++ b/iOS/AskKeyiOSKit/Views/WelcomeView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct WelcomeView: View { + let model: AppModel + + var body: some View { + WelcomeContent(step: model.welcomeStep) { + Task { await model.runWelcomeChecks() } + } + .screenScaffold() + } +} + +/// The welcome screen: what the app needs, the wireless-data prompt some +/// iPhones show before the first network access, and each failed check. +struct WelcomeContent: View { + let step: WelcomeStep + let onContinue: () -> Void + + var body: some View { + ScreenContent(title: "welcome.title", subtitle: "welcome.subtitle") { + VStack(alignment: .leading, spacing: 16) { + requirement("lock.shield", "welcome.requirement.secureEnclave") + requirement("faceid", "welcome.requirement.biometry") + requirement("icloud", "welcome.requirement.icloud") + requirement("bell.badge", "welcome.requirement.notifications") + } + NoticeCard(symbol: "antenna.radiowaves.left.and.right", text: Text("welcome.wirelessData", bundle: .kit)) + if case .failed(let failure) = step { + NoticeCard(symbol: "exclamationmark.triangle.fill", tone: .warning, + text: Text(Self.explanation(failure), bundle: .kit)) + } + Button(action: onContinue) { + Text(buttonTitle, bundle: .kit) + } + .buttonStyle(ActionButtonStyle()) + .disabled(step == .checking) + } + } + + private var buttonTitle: LocalizedStringKey { + switch step { + case .intro: return "welcome.continue" + case .checking: return "welcome.checking" + case .failed: return "welcome.tryAgain" + } + } + + private func requirement(_ symbol: String, _ text: LocalizedStringKey) -> some View { + HStack(alignment: .top, spacing: 14) { + Image(systemName: symbol) + .font(.title2) + .foregroundStyle(Color.accentColor) + .frame(width: 32) + Text(text, bundle: .kit) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + } + } + + static func explanation(_ failure: WelcomeFailure) -> LocalizedStringKey { + switch failure { + case .noSecureEnclave: return "welcome.failure.noSecureEnclave" + case .biometryNotEnrolled(.faceID): return "welcome.failure.faceIDNotEnrolled" + case .biometryNotEnrolled(.touchID): return "welcome.failure.touchIDNotEnrolled" + case .passcodeNotSet: return "welcome.failure.passcodeNotSet" + case .biometryDenied: return "welcome.failure.biometryDenied" + case .noBiometry: return "welcome.failure.noBiometry" + case .cloudNotConfigured: return "welcome.failure.cloudNotConfigured" + case .iCloudSignedOut: return "welcome.failure.iCloudSignedOut" + case .iCloudRestricted: return "welcome.failure.iCloudRestricted" + case .iCloudUnavailable: return "welcome.failure.iCloudUnavailable" + case .keyCreationFailed: return "welcome.failure.keyCreationFailed" + case .keychainFailed: return "welcome.failure.keychainFailed" + case .networkUnavailable: return "welcome.failure.networkUnavailable" + case .iCloudFailed: return "welcome.failure.iCloudFailed" + } + } +} diff --git a/iOS/AskKeyiOSKitTests/Fakes/FakeCloud.swift b/iOS/AskKeyiOSKitTests/Fakes/FakeCloud.swift new file mode 100644 index 0000000..6aa4508 --- /dev/null +++ b/iOS/AskKeyiOSKitTests/Fakes/FakeCloud.swift @@ -0,0 +1,167 @@ +import AskKeyDevices +import Foundation +@testable import AskKeyiOSKit + +/// The account's private database in memory: one inbox per device, change +/// tokens, and the account state every transport of the account sees. +final class FakeCloud: @unchecked Sendable { + private struct Record { + let envelope: DeviceEnvelope + let sequence: Int + } + + private let lock = NSLock() + private var inboxes: [String: [String: Record]] = [:] + private var sequence = 0 + private var sent: [DeviceEnvelope] = [] + private var sendFilter: (@Sendable (DeviceEnvelope) -> Bool)? + private var state: CloudKitAccountState = .available(userRecordName: "_user-a") + private var preparationError: DeviceTransportError? + private var preparationIncomplete = false + private(set) var preparedDevices: [DeviceID] = [] + private(set) var removedInboxes: [DeviceID] = [] + + func transport(for device: DeviceID) -> FakeInboxTransport { + FakeInboxTransport(cloud: self, device: device) + } + + var accountState: CloudKitAccountState { + get { lock.withLock { state } } + set { lock.withLock { state = newValue } } + } + + var prepareError: DeviceTransportError? { + get { lock.withLock { preparationError } } + set { lock.withLock { preparationError = newValue } } + } + + var prepareLeavesSubscriptionsPending: Bool { + get { lock.withLock { preparationIncomplete } } + set { lock.withLock { preparationIncomplete = newValue } } + } + + /// Sends for which `filter` returns true fail as if the network were down. + func failSends(where filter: (@Sendable (DeviceEnvelope) -> Bool)?) { + lock.withLock { sendFilter = filter } + } + + /// Every envelope a device tried to send, in order, including failed sends. + var sentEnvelopes: [DeviceEnvelope] { lock.withLock { sent } } + + func sent(_ kind: MessageKind, from device: DeviceID) -> [DeviceEnvelope] { + sentEnvelopes.filter { $0.kind == Int64(kind.rawValue) && $0.sender == device.text } + } + + func records(in device: DeviceID) -> [DeviceEnvelope] { + lock.withLock { + (inboxes[device.text] ?? [:]).values.sorted { $0.sequence < $1.sequence }.map(\.envelope) + } + } + + fileprivate func send(_ envelope: DeviceEnvelope) throws { + try lock.withLock { + sent.append(envelope) + if sendFilter?(envelope) == true { throw DeviceTransportError.networkUnavailable } + sequence += 1 + inboxes[envelope.recipient, default: [:]][envelope.recordName] = Record(envelope: envelope, sequence: sequence) + } + } + + fileprivate func fetch(_ device: DeviceID, since token: InboxChangeToken?) -> InboxChanges { + lock.withLock { + let after = token.map { Int(String(decoding: $0.data, as: UTF8.self)) ?? 0 } ?? 0 + let changed = (inboxes[device.text] ?? [:]).values.filter { $0.sequence > after }.sorted { $0.sequence < $1.sequence } + return InboxChanges(envelopes: changed.map(\.envelope), newToken: InboxChangeToken(data: Data(String(sequence).utf8))) + } + } + + fileprivate func delete(_ names: [String], from device: DeviceID) { + lock.withLock { for name in names { inboxes[device.text]?[name] = nil } } + } + + fileprivate func prepare(_ device: DeviceID) throws -> InboxPreparation { + try lock.withLock { + if let preparationError { throw preparationError } + preparedDevices.append(device) + return preparationIncomplete ? InboxPreparation(pending: ["pending"]) : InboxPreparation() + } + } + + fileprivate func removeInbox(_ device: DeviceID) { + lock.withLock { + removedInboxes.append(device) + inboxes[device.text] = nil + } + } +} + +final class FakeInboxTransport: InboxTransport, @unchecked Sendable { + let cloud: FakeCloud + let device: DeviceID + + init(cloud: FakeCloud, device: DeviceID) { + self.cloud = cloud + self.device = device + } + + func send(_ envelope: DeviceEnvelope) async throws { + try cloud.send(envelope) + } + + func fetchInboxChanges(of device: DeviceID, since token: InboxChangeToken?) async throws -> InboxChanges { + cloud.fetch(device, since: token) + } + + func deleteMessages(_ recordNames: [String], from device: DeviceID) async throws { + cloud.delete(recordNames, from: device) + } + + func accountState(storedUserRecordName: String?) async -> CloudKitAccountState { + let state = cloud.accountState + if case .available(let name) = state, let storedUserRecordName, storedUserRecordName != name { + return .accountChanged(userRecordName: name) + } + return state + } + + func prepareInbox() async throws -> InboxPreparation { + try cloud.prepare(device) + } + + func removeInbox() async throws { + cloud.removeInbox(device) + } +} + +/// The CloudKit gate: configured or not, and which transports were made. +final class FakeCloudFactory: InboxTransportFactory, @unchecked Sendable { + let cloud: FakeCloud + private let lock = NSLock() + private var configured = true + private var made: [DeviceID] = [] + private var continuations: [AsyncStream.Continuation] = [] + + init(cloud: FakeCloud) { + self.cloud = cloud + } + + var isConfigured: Bool { + get { lock.withLock { configured } } + set { lock.withLock { configured = newValue } } + } + + /// The devices a transport was made for, in order. + var madeTransports: [DeviceID] { lock.withLock { made } } + + func makeTransport(for device: DeviceID) -> (any InboxTransport)? { + lock.withLock { + guard configured else { return nil } + made.append(device) + return cloud.transport(for: device) + } + } + + func accountChanges() -> AsyncStream { + AsyncStream { continuation in lock.withLock { continuations.append(continuation) } } + } +} diff --git a/iOS/AskKeyiOSKitTests/Fakes/FakeSystem.swift b/iOS/AskKeyiOSKitTests/Fakes/FakeSystem.swift new file mode 100644 index 0000000..9ed61d5 --- /dev/null +++ b/iOS/AskKeyiOSKitTests/Fakes/FakeSystem.swift @@ -0,0 +1,121 @@ +import AskKeyDevices +import Foundation +@testable import AskKeyiOSKit + +final class InMemoryKeychain: SecureItemStore, @unchecked Sendable { + private let lock = NSLock() + private var items: [String: Data] = [:] + private var failWrites = false + + var accounts: Set { lock.withLock { Set(items.keys) } } + + var writesFail: Bool { + get { lock.withLock { failWrites } } + set { lock.withLock { failWrites = newValue } } + } + + func data(for account: String) throws -> Data? { + lock.withLock { items[account] } + } + + func setData(_ data: Data, for account: String) throws { + try lock.withLock { + if failWrites { throw KeychainError(status: errSecIO) } + items[account] = data + } + } + + func deleteData(for account: String) throws { + lock.withLock { items[account] = nil } + } +} + +final class InMemoryLocalState: LocalStateStore { + var inboxPosition: InboxPosition? + var deviceID: String? + var deliveryRecords: [DeliveryRecord] = [] + + func removeAll() { + inboxPosition = nil + deviceID = nil + deliveryRecords = [] + } +} + +final class FakeBiometry: BiometryChecking, @unchecked Sendable { + var current: BiometryStatus = .enrolled(.faceID) + + func status() -> BiometryStatus { current } +} + +final class FakeCamera: CameraAccessChecking, @unchecked Sendable { + var current: CameraAccess = .unavailable + var grantsAccess = true + + func access() -> CameraAccess { current } + + func requestAccess() async -> Bool { + current = grantsAccess ? .authorized : .denied + return grantsAccess + } +} + +final class FakeNotifications: NotificationCenterAccess, @unchecked Sendable { + private let lock = NSLock() + private var hints: [PushHint] = [] + private var removed: [String] = [] + private var requests = 0 + + var authorizationRequests: Int { lock.withLock { requests } } + var removedIdentifiers: [String] { lock.withLock { removed } } + + /// The pushes Notification Center holds for this device. + var delivered: [PushHint] { + get { lock.withLock { hints } } + set { lock.withLock { hints = newValue } } + } + + func requestAuthorizationAndRegister() async { + lock.withLock { requests += 1 } + } + + func registerAtLaunch() async {} + + func deliveredPushHints(for device: DeviceID) async -> [PushHint] { + delivered + } + + func removeDeliveredNotifications(identifiers: [String]) { + lock.withLock { + removed += identifiers + hints.removeAll { $0.notificationIdentifier.map(identifiers.contains) ?? false } + } + } +} + +final class ManualClock: DeviceClock, @unchecked Sendable { + private let lock = NSLock() + private var current = Date(timeIntervalSince1970: 1_800_000_000) + + func now() -> Date { lock.withLock { current } } + + func advance(by interval: TimeInterval) { + lock.withLock { current = current.addingTimeInterval(interval) } + } +} + +/// A list store in memory, through the list's serialization like the keychain store. +final class InMemoryListStore: PairedDeviceListStore, @unchecked Sendable { + private let lock = NSLock() + private var stored: Data? + + func loadPairedDeviceList() throws -> PairedDeviceList { + guard let data = lock.withLock({ stored }) else { return PairedDeviceList() } + return try PairedDeviceList(serialized: data) + } + + func savePairedDeviceList(_ list: PairedDeviceList) throws { + let data = try list.serialized() + lock.withLock { stored = data } + } +} diff --git a/iOS/AskKeyiOSKitTests/Fakes/SoftwareIdentity.swift b/iOS/AskKeyiOSKitTests/Fakes/SoftwareIdentity.swift new file mode 100644 index 0000000..fdfd086 --- /dev/null +++ b/iOS/AskKeyiOSKitTests/Fakes/SoftwareIdentity.swift @@ -0,0 +1,122 @@ +import AskKeyDevices +import CryptoKit +import Foundation +@testable import AskKeyiOSKit + +/// A device identity with software P-256 keys, for tests only. It can +/// simulate a cancelled or failed Face ID prompt and keys that no longer work. +final class SoftwareIdentity: DeviceIdentity, @unchecked Sendable { + enum ApprovalBehavior { + case sign + case cancel + case fail + } + + private let messageKey = P256.Signing.PrivateKey() + private let agreementKey = P256.KeyAgreement.PrivateKey() + private let approvalKey = P256.Signing.PrivateKey() + private let lock = NSLock() + private var unusable = false + private var behavior = ApprovalBehavior.sign + private var reasons: [String] = [] + + var messagePublicKey: P256.Signing.PublicKey { messageKey.publicKey } + var agreementPublicKey: P256.KeyAgreement.PublicKey { agreementKey.publicKey } + var approvalPublicKey: P256.Signing.PublicKey { approvalKey.publicKey } + var deviceID: DeviceID { DeviceID(messagePublicKey: messagePublicKey) } + + var isUnusable: Bool { + get { lock.withLock { unusable } } + set { lock.withLock { unusable = newValue } } + } + + var approvalBehavior: ApprovalBehavior { + get { lock.withLock { behavior } } + set { lock.withLock { behavior = newValue } } + } + + /// The prompt reason of every approval signature asked for. + var approvalReasons: [String] { lock.withLock { reasons } } + + func signWithMessageKey(_ data: Data) throws -> Data { + guard !isUnusable else { throw DeviceIdentityError.unusable } + return try messageKey.signature(for: data).rawRepresentation + } + + func signWithApprovalKey(_ data: Data, reason: String) async throws -> Data { + let behavior: ApprovalBehavior = lock.withLock { + reasons.append(reason) + return self.behavior + } + guard !isUnusable else { throw DeviceIdentityError.unusable } + switch behavior { + case .sign: return try approvalKey.signature(for: data).rawRepresentation + case .cancel: throw DeviceIdentityError.authenticationCancelled + case .fail: throw DeviceIdentityError.authenticationFailed + } + } + + func openHPKE(_ ciphertext: Data, encapsulatedKey: Data, info: Data, + preSharedKey: HPKEPreSharedKey?) throws -> Data { + guard !isUnusable else { throw DeviceIdentityError.unusable } + var recipient: HPKE.Recipient + if let preSharedKey { + recipient = try HPKE.Recipient(privateKey: agreementKey, ciphersuite: .P256_SHA256_AES_GCM_256, info: info, + encapsulatedKey: encapsulatedKey, presharedKey: preSharedKey.key, + presharedKeyIdentifier: preSharedKey.identifier) + } else { + recipient = try HPKE.Recipient(privateKey: agreementKey, ciphersuite: .P256_SHA256_AES_GCM_256, info: info, + encapsulatedKey: encapsulatedKey) + } + return try recipient.open(ciphertext) + } +} + +/// Creates software identities and restores them from their fake blobs. +final class FakeIdentityFactory: DeviceIdentityFactory, @unchecked Sendable { + private let lock = NSLock() + private var identities: [Data: SoftwareIdentity] = [:] + private var available = true + private var creationError: DeviceIdentityError? + private var restoreError: DeviceIdentityError? + private(set) var createdCount = 0 + + var isSecureEnclaveAvailable: Bool { + get { lock.withLock { available } } + set { lock.withLock { available = newValue } } + } + + var createError: DeviceIdentityError? { + get { lock.withLock { creationError } } + set { lock.withLock { creationError = newValue } } + } + + var restoringError: DeviceIdentityError? { + get { lock.withLock { restoreError } } + set { lock.withLock { restoreError = newValue } } + } + + /// The identity created last. + private(set) var latest: SoftwareIdentity? + + func create() throws -> (identity: any DeviceIdentity, blobs: DeviceIdentityKeyBlobs) { + try lock.withLock { + if let creationError { throw creationError } + let identity = SoftwareIdentity() + let blobs = DeviceIdentityKeyBlobs(message: Data(UUID().uuidString.utf8), agreement: Data(UUID().uuidString.utf8), + approval: Data(UUID().uuidString.utf8)) + identities[blobs.message] = identity + createdCount += 1 + latest = identity + return (identity, blobs) + } + } + + func restore(_ blobs: DeviceIdentityKeyBlobs) throws -> any DeviceIdentity { + try lock.withLock { + if let restoreError { throw restoreError } + guard let identity = identities[blobs.message] else { throw DeviceIdentityError.unusable } + return identity + } + } +} diff --git a/iOS/AskKeyiOSKitTests/Fakes/TestWorld.swift b/iOS/AskKeyiOSKitTests/Fakes/TestWorld.swift new file mode 100644 index 0000000..4f7a03b --- /dev/null +++ b/iOS/AskKeyiOSKitTests/Fakes/TestWorld.swift @@ -0,0 +1,83 @@ +import AskKeyDevices +import Foundation +import XCTest +@testable import AskKeyiOSKit + +/// One iPhone app with fakes for keys, keychain and transport, and a Mac +/// played by a real `DeviceMessenger` on the same in-memory account. +@MainActor +final class TestWorld { + let clock = ManualClock() + let cloud = FakeCloud() + let factory: FakeCloudFactory + let keychain = InMemoryKeychain() + let localState = InMemoryLocalState() + let identities = FakeIdentityFactory() + let biometry = FakeBiometry() + let camera = FakeCamera() + let notifications = FakeNotifications() + private(set) lazy var model = AppModel(environment: environment) + + let macIdentity = SoftwareIdentity() + private(set) lazy var mac = DeviceMessenger(identity: macIdentity, platform: .macOS, + registry: PairedDeviceRegistry(store: InMemoryListStore()), + transport: cloud.transport(for: macIdentity.deviceID), clock: clock) + static let macName = "Studio Mac" + + init() { + factory = FakeCloudFactory(cloud: cloud) + } + + var environment: AppEnvironment { + AppEnvironment(identities: identities, keychain: keychain, localState: localState, biometry: biometry, + camera: camera, notifications: notifications, cloud: factory, clock: clock, + deviceName: { "iPhone" }) + } + + /// The iPhone's identity once the welcome checks created it. + var phone: SoftwareIdentity { + get throws { try XCTUnwrap(identities.latest) } + } + + /// Launches the app and passes the welcome checks. + func setUpPhone(file: StaticString = #filePath, line: UInt = #line) async { + await model.start() + await model.runWelcomeChecks() + XCTAssertEqual(model.stage, .ready, file: file, line: line) + } + + /// Starts pairing on the Mac and scans its QR code on the iPhone. + func scanMacCode() async throws -> PairingFlowModel { + let qr = try await mac.startPairing(displayName: Self.macName) + model.startPairing() + let flow = try XCTUnwrap(model.pairing) + await flow.handleScanned(qr.text) + return flow + } + + /// Runs pairing up to the code on the iPhone: the Mac answers the + /// request, and the iPhone verifies the challenge and saves its reveal. + func reachCode() async throws -> (PairingFlowModel, PairingCodeDisplay) { + let flow = try await scanMacCode() + _ = try await mac.poll() + await flow.refresh() + guard case .code(let display) = flow.stage else { + XCTFail("expected the code, got \(flow.stage)") + throw CancellationError() + } + return (flow, display) + } + + /// Completes pairing on both sides. + func pair() async throws { + let (flow, display) = try await reachCode() + _ = try await mac.poll() + try await mac.enterPairingCode(display.digits) + try await mac.confirmPairing(approvalReason: "Pair") + await flow.confirm() + await flow.refresh() + _ = try await mac.poll() + XCTAssertEqual(flow.stage, .paired(macName: Self.macName)) + await model.closePairingAndWait() + } +} diff --git a/iOS/AskKeyiOSKitTests/IdentityResetTests.swift b/iOS/AskKeyiOSKitTests/IdentityResetTests.swift new file mode 100644 index 0000000..6a2828b --- /dev/null +++ b/iOS/AskKeyiOSKitTests/IdentityResetTests.swift @@ -0,0 +1,76 @@ +import AskKeyDevices +import XCTest +@testable import AskKeyiOSKit + +@MainActor +final class IdentityResetTests: XCTestCase { + func testKeysThatNoLongerRestoreShowTheResetAndResetDeletesEverything() async throws { + let world = TestWorld() + await world.setUpPhone() + try await world.pair() + let phoneID = try world.phone.deviceID + + world.identities.restoringError = .unusable + let relaunched = AppModel(environment: world.environment) + await relaunched.start() + XCTAssertEqual(relaunched.stage, .unusableIdentity) + + await relaunched.resetIdentity() + XCTAssertEqual(relaunched.stage, .welcome) + XCTAssertEqual(relaunched.welcomeStep, .intro) + XCTAssertTrue(world.keychain.accounts.isEmpty, "keys and list are deleted") + XCTAssertNil(world.localState.inboxPosition) + XCTAssertNil(world.localState.deviceID) + XCTAssertEqual(world.cloud.removedInboxes, [phoneID], "the inbox is deleted using the stored device ID") + XCTAssertTrue(relaunched.pairedMacs.isEmpty) + } + + func testAKeyThatStopsWorkingWhileRunningShowsTheReset() async throws { + let world = TestWorld() + await world.setUpPhone() + try await world.pair() + let phone = try world.phone + _ = try await world.mac.sendDeliveryCheck(to: phone.deviceID) + phone.isUnusable = true + await world.model.processInbox() + XCTAssertEqual(world.model.stage, .unusableIdentity) + + await world.model.resetIdentity() + XCTAssertEqual(world.model.stage, .welcome) + XCTAssertTrue(world.keychain.accounts.isEmpty) + XCTAssertEqual(world.cloud.removedInboxes, [phone.deviceID]) + XCTAssertTrue(world.model.deliveryRecords.isEmpty) + + world.identities.restoringError = nil + await world.model.runWelcomeChecks() + XCTAssertEqual(world.model.stage, .ready) + XCTAssertNotEqual(try world.phone.deviceID, phone.deviceID, "pairing again starts from a new identity") + } + + func testIncompleteStoredKeysAreUnusable() async throws { + let world = TestWorld() + try world.keychain.setData(Data([1]), for: "device-message-key") + await world.model.start() + XCTAssertEqual(world.model.stage, .unusableIdentity) + await world.model.resetIdentity() + XCTAssertEqual(world.model.stage, .welcome) + XCTAssertTrue(world.keychain.accounts.isEmpty) + } + + func testAStoredListThatDoesNotDecodeIsUnusable() async throws { + let world = TestWorld() + await world.setUpPhone() + try world.keychain.setData(Data("not a list".utf8), for: "paired-devices") + let relaunched = AppModel(environment: world.environment) + await relaunched.start() + XCTAssertEqual(relaunched.stage, .unusableIdentity) + } + + func testResetOnlyFromTheUnusableScreen() async throws { + let world = TestWorld() + await world.setUpPhone() + await world.model.resetIdentity() + XCTAssertEqual(world.model.stage, .ready) + XCTAssertEqual(world.keychain.accounts.count, 4) + } +} diff --git a/iOS/AskKeyiOSKitTests/LocalizationTests.swift b/iOS/AskKeyiOSKitTests/LocalizationTests.swift new file mode 100644 index 0000000..c57a535 --- /dev/null +++ b/iOS/AskKeyiOSKitTests/LocalizationTests.swift @@ -0,0 +1,70 @@ +import AskKeyDevices +import CloudKit +import XCTest +@testable import AskKeyiOSKit + +final class LocalizationTests: XCTestCase { + private func table(_ language: String) throws -> [String: String] { + let url = try XCTUnwrap(Bundle.kit.url(forResource: "Localizable", withExtension: "strings", + subdirectory: nil, localization: language), + "the kit bundle has no \(language) strings") + return try XCTUnwrap(NSDictionary(contentsOf: url) as? [String: String]) + } + + func testEveryStringHasEnglishAndSimplifiedChineseThatDiffer() throws { + let english = try table("en") + let chinese = try table("zh-Hans") + XCTAssertFalse(english.isEmpty) + XCTAssertEqual(Set(english.keys), Set(chinese.keys)) + for (key, value) in english { + XCTAssertNotEqual(value, key, "\(key) has no English text") + XCTAssertNotEqual(chinese[key], value, "\(key) is not translated") + } + } + + func testCodeSourceAndScanningInstructionsSayWhatTheyMust() throws { + let english = try table("en") + XCTAssertTrue(try XCTUnwrap(english["pairing.code.onlyHere"]).contains("never sends it in a notification or a message")) + XCTAssertTrue(try XCTUnwrap(english["scanner.instruction"]).contains("on your own Mac")) + XCTAssertTrue(try XCTUnwrap(english["scanner.instruction"]).contains("Never scan a pairing code that someone sends you")) + XCTAssertTrue(try XCTUnwrap(english["pairing.failed.noChallenge"]).contains("don't enter any code")) + } + + func testAlertKeysMatchTheSubscription() { + XCTAssertEqual(CloudKitInboxLayout.alertTitleLocalizationKey, "push.message.title") + XCTAssertEqual(CloudKitInboxLayout.alertBodyLocalizationKey, "push.message.body") + } +} + +final class PushHintReaderTests: XCTestCase { + private let device = DeviceID(rawValue: Data(repeating: 7, count: 16))! + private let other = DeviceID(rawValue: Data(repeating: 9, count: 16))! + + private func payload(subscription: String, zone: String, record: String) -> [AnyHashable: Any] { + [ + "aps": ["alert": ["title-loc-key": "push.message.title", "loc-key": "push.message.body"], "mutable-content": 1], + "ck": [ + "ce": 2, + "cid": "iCloud.com.sudohg.askkey", + "nid": UUID().uuidString, + "qry": ["dbs": 1, "fo": 1, "rid": record, "sid": subscription, "zid": zone, "zoid": "_defaultOwner"], + ] as [String: Any], + ] + } + + func testAQueryNotificationForThisInboxIsAHint() throws { + let zone = CloudKitInboxLayout.zoneName(for: device) + let info = payload(subscription: zone + "_alert", zone: zone, record: "abc") + let arrival = Date(timeIntervalSince1970: 1_800_000_000) + let hint = PushHintReader.hint(userInfo: info, arrivedAt: arrival, notificationIdentifier: "n", for: device) + XCTAssertEqual(hint, PushHint(recordName: "abc", arrivedAt: arrival, notificationIdentifier: "n")) + } + + func testAnotherDevicesPushOrAPlainAlertIsNotAHint() { + let zone = CloudKitInboxLayout.zoneName(for: other) + let foreign = payload(subscription: zone + "_alert", zone: zone, record: "abc") + XCTAssertNil(PushHintReader.hint(userInfo: foreign, arrivedAt: Date(), notificationIdentifier: nil, for: device)) + let plain: [AnyHashable: Any] = ["aps": ["alert": "Enter 123 456 on your Mac"]] + XCTAssertNil(PushHintReader.hint(userInfo: plain, arrivedAt: Date(), notificationIdentifier: nil, for: device)) + } +} diff --git a/iOS/AskKeyiOSKitTests/PairedMacsTests.swift b/iOS/AskKeyiOSKitTests/PairedMacsTests.swift new file mode 100644 index 0000000..1a95e10 --- /dev/null +++ b/iOS/AskKeyiOSKitTests/PairedMacsTests.swift @@ -0,0 +1,124 @@ +import AskKeyDevices +import XCTest +@testable import AskKeyiOSKit + +@MainActor +final class PairedMacsTests: XCTestCase { + private var world: TestWorld! + + override func setUp() async throws { + world = TestWorld() + await world.setUpPhone() + try await world.pair() + } + + override func tearDown() async throws { + world = nil + } + + private var macRowID: String { world.macIdentity.deviceID.text } + + func testRemovalTakesEffectAtOnceAndSendsAnUnpairNotice() async throws { + let phoneID = try world.phone.deviceID + await world.model.removeMac(id: macRowID) + XCTAssertTrue(world.model.pairedMacs.isEmpty) + XCTAssertEqual(world.model.listNotice, .removed(name: TestWorld.macName, macWasTold: true)) + XCTAssertEqual(world.cloud.sent(.unpairNotice, from: phoneID).count, 1) + _ = try await world.mac.poll() + let macList = try await world.mac.pairedDevices() + XCTAssertTrue(macList.isEmpty, "the Mac removes this iPhone when it processes the notice") + } + + func testRemovalWhenTheNoticeCannotBeSentSaysSo() async throws { + world.cloud.failSends { $0.kind == Int64(MessageKind.unpairNotice.rawValue) } + await world.model.removeMac(id: macRowID) + XCTAssertTrue(world.model.pairedMacs.isEmpty) + XCTAssertEqual(world.model.listNotice, .removed(name: TestWorld.macName, macWasTold: false)) + } + + func testDeliveryCheckIsAnsweredAtOnceWithoutAPromptAndRecordedAsPolling() async throws { + let phone = try world.phone + let prompts = phone.approvalReasons.count + let check = try await world.mac.sendDeliveryCheck(to: phone.deviceID) + world.clock.advance(by: 3) + await world.model.becameActive() + XCTAssertEqual(world.cloud.sent(.deliveryCheckReply, from: phone.deviceID).count, 1) + XCTAssertEqual(phone.approvalReasons.count, prompts, "a delivery reply never asks for Face ID") + XCTAssertEqual(world.model.pairedMacs.first?.lastReachedAt, world.clock.now()) + XCTAssertEqual(world.model.deliveryRecords, + [DeliveryRecord(recordName: check.text, arrivedAt: world.clock.now(), source: .polling)]) + XCTAssertEqual(world.localState.deliveryRecords, world.model.deliveryRecords) + _ = try await world.mac.poll() + let macList = try await world.mac.pairedDevices() + XCTAssertEqual(macList.first?.isReached, true) + } + + func testDeliveredNotificationIsRecordedWithItsArrivalTimeAndCleared() async throws { + let phoneID = try world.phone.deviceID + let check = try await world.mac.sendDeliveryCheck(to: phoneID) + let arrival = world.clock.now().addingTimeInterval(1) + world.notifications.delivered = [PushHint(recordName: check.text, arrivedAt: arrival, notificationIdentifier: "n1")] + world.clock.advance(by: 30) + await world.model.becameActive() + XCTAssertEqual(world.model.deliveryRecords, [DeliveryRecord(recordName: check.text, arrivedAt: arrival, source: .push)]) + XCTAssertEqual(world.notifications.removedIdentifiers, ["n1"]) + XCTAssertEqual(world.cloud.sent(.deliveryCheckReply, from: phoneID).count, 1) + } + + func testPushReceivedWhileRunningForAnotherDeviceIsIgnored() async throws { + let phoneID = try world.phone.deviceID + let check = try await world.mac.sendDeliveryCheck(to: phoneID) + await world.model.receivedPush(userInfo: ["aps": ["alert": "Approve now"]], arrivedAt: world.clock.now(), + notificationIdentifier: "foreign") + XCTAssertTrue(world.cloud.sent(.deliveryCheckReply, from: phoneID).isEmpty, + "a notification that is not a hint for this inbox triggers nothing") + await world.model.processInbox() + XCTAssertEqual(world.model.deliveryRecords.map(\.recordName), [check.text]) + XCTAssertEqual(world.model.deliveryRecords.first?.source, .polling) + } + + func testUnpairNoticeFromTheMacRemovesItAndSaysSo() async throws { + let phoneID = try world.phone.deviceID + try await world.mac.removeDevice(phoneID) + await world.model.becameActive() + XCTAssertTrue(world.model.pairedMacs.isEmpty) + XCTAssertEqual(world.model.listNotice, .removedByMac(name: TestWorld.macName)) + } + + func testMessagesFromARemovedMacAreRefused() async throws { + let phoneID = try world.phone.deviceID + await world.model.removeMac(id: macRowID) + world.model.listNotice = nil + // The Mac has not processed the notice yet and still sends a check. + _ = try? await world.mac.sendDeliveryCheck(to: phoneID) + await world.model.processInbox() + XCTAssertTrue(world.cloud.sent(.deliveryCheckReply, from: phoneID).isEmpty) + XCTAssertTrue(world.model.deliveryRecords.isEmpty) + } + + func testSignedOutAccountShowsMacsAsUnreachableAndKeepsTheList() async throws { + world.cloud.accountState = .noAccount + await world.model.refreshAccount() + XCTAssertEqual(world.model.accountNotice, .signedOut) + XCTAssertEqual(world.model.pairedMacs.count, 1) + world.cloud.accountState = .available(userRecordName: "_user-a") + await world.model.processInbox() + XCTAssertNil(world.model.accountNotice) + } + + func testAccountChangeDropsTheTokenAndPreparesTheInboxAgain() async throws { + let phoneID = try world.phone.deviceID + world.cloud.accountState = .available(userRecordName: "_user-b") + await world.model.refreshAccount() + XCTAssertEqual(world.localState.inboxPosition, InboxPosition(userRecordName: "_user-b", token: nil)) + XCTAssertEqual(world.cloud.preparedDevices.filter { $0 == phoneID }.count, 2) + XCTAssertEqual(world.model.pairedMacs.count, 1, "the list is kept") + } + + func testTokenIsStoredWithTheAccountAfterAPoll() async throws { + _ = try await world.mac.sendDeliveryCheck(to: try world.phone.deviceID) + await world.model.processInbox() + XCTAssertEqual(world.localState.inboxPosition?.userRecordName, "_user-a") + XCTAssertNotNil(world.localState.inboxPosition?.token) + } +} diff --git a/iOS/AskKeyiOSKitTests/PairingFlowTests.swift b/iOS/AskKeyiOSKitTests/PairingFlowTests.swift new file mode 100644 index 0000000..a795a8b --- /dev/null +++ b/iOS/AskKeyiOSKitTests/PairingFlowTests.swift @@ -0,0 +1,210 @@ +import AskKeyDevices +import XCTest +@testable import AskKeyiOSKit + +@MainActor +final class PairingFlowTests: XCTestCase { + private var world: TestWorld! + + override func setUp() async throws { + world = TestWorld() + await world.setUpPhone() + } + + override func tearDown() async throws { + world = nil + } + + // MARK: Scanner + + func testScannerRefusesInvalidForeignAndExpiredCodesWithTheirOwnMessages() async throws { + world.model.startPairing() + let flow = try XCTUnwrap(world.model.pairing) + XCTAssertEqual(flow.camera, .unavailable) + + await flow.handleScanned("https://example.com/not-a-pairing-code") + XCTAssertEqual(flow.scanProblem, .notAPairingCode) + XCTAssertEqual(flow.stage, .scanning) + + await flow.handleScanned(PairingQRPayload.prefix + "damaged") + XCTAssertEqual(flow.scanProblem, .invalid) + + let qr = try await world.mac.startPairing(displayName: TestWorld.macName) + world.clock.advance(by: 5 * 60 + 1) + await flow.handleScanned(qr.text) + XCTAssertEqual(flow.scanProblem, .expired) + XCTAssertEqual(flow.stage, .scanning) + XCTAssertTrue(world.cloud.sent(.pairingRequest, from: try world.phone.deviceID).isEmpty) + } + + func testARefusedCodeThatStaysInViewIsNotHandledAgainUntilDismissed() async throws { + world.model.startPairing() + let flow = try XCTUnwrap(world.model.pairing) + await flow.handleScanned("not a pairing code") + flow.scanProblem = nil + await flow.handleScanned("not a pairing code") + XCTAssertNil(flow.scanProblem) + flow.dismissScanProblem() + await flow.handleScanned("not a pairing code") + XCTAssertEqual(flow.scanProblem, .notAPairingCode) + } + + func testCameraIsRequestedOnceWhenNotDetermined() async throws { + world.camera.current = .notDetermined + world.model.startPairing() + let flow = try XCTUnwrap(world.model.pairing) + await flow.prepareCamera() + XCTAssertEqual(flow.camera, .authorized) + } + + // MARK: Code + + func testNoCodeBeforeTheChallenge() async throws { + let flow = try await world.scanMacCode() + XCTAssertEqual(flow.stage, .waitingForCode) + XCTAssertEqual(world.cloud.sent(.pairingRequest, from: try world.phone.deviceID).count, 1) + await flow.refresh() + XCTAssertEqual(flow.stage, .waitingForCode, "the Mac has not answered") + } + + func testNoCodeUntilTheRevealIsSavedAndTheRetryKeepsItsMessageID() async throws { + let phoneID = try world.phone.deviceID + world.cloud.failSends { $0.kind == Int64(MessageKind.pairingReveal.rawValue) } + let flow = try await world.scanMacCode() + _ = try await world.mac.poll() + await flow.refresh() + XCTAssertEqual(flow.stage, .waitingForCode, "a verified challenge alone shows no code") + world.cloud.failSends(where: nil) + await flow.refresh() + guard case .code(let display) = flow.stage else { return XCTFail("expected the code, got \(flow.stage)") } + XCTAssertEqual(display.macName, TestWorld.macName) + XCTAssertFalse(display.confirmed) + XCTAssertEqual(display.digits.count, 7) + let reveals = world.cloud.sent(.pairingReveal, from: phoneID) + XCTAssertGreaterThanOrEqual(reveals.count, 2) + XCTAssertEqual(Set(reveals.map(\.recordName)).count, 1, "a failed save is retried with the same message ID") + } + + func testTheCodeMatchesWhatTheMacExpects() async throws { + let (_, display) = try await world.reachCode() + _ = try await world.mac.poll() + try await world.mac.enterPairingCode(display.digits) + guard case .codeAccepted? = await world.mac.pairingPhase else { return XCTFail("the Mac refused the code") } + } + + func testConfirmSignsWithTheApprovalKeyAndSuccessWaitsForTheMac() async throws { + let phone = try world.phone + let (flow, display) = try await world.reachCode() + await flow.confirm() + XCTAssertEqual(phone.approvalReasons.count, 1) + XCTAssertTrue(phone.approvalReasons[0].contains(TestWorld.macName)) + XCTAssertEqual(world.cloud.sent(.pairingConfirmation, from: phone.deviceID).count, 1) + XCTAssertEqual(flow.stage, .code(PairingCodeDisplay(digits: display.digits, macName: TestWorld.macName, + confirmed: true))) + XCTAssertTrue(world.model.pairedMacs.isEmpty, "nothing is stored before the Mac's confirmation") + + _ = try await world.mac.poll() + try await world.mac.enterPairingCode(display.digits) + try await world.mac.confirmPairing(approvalReason: "Pair") + await flow.refresh() + XCTAssertEqual(flow.stage, .paired(macName: TestWorld.macName)) + XCTAssertEqual(world.model.pairedMacs.map(\.name), [TestWorld.macName]) + XCTAssertNil(world.model.pairedMacs.first?.lastReachedAt, "not reached yet until a delivery check") + _ = try await world.mac.poll() + let macList = try await world.mac.pairedDevices() + XCTAssertEqual(macList.map(\.deviceID), [phone.deviceID]) + } + + func testCancelledFaceIDSendsNothingAndCanBeRetried() async throws { + let phone = try world.phone + let (flow, _) = try await world.reachCode() + phone.approvalBehavior = .cancel + await flow.confirm() + XCTAssertEqual(flow.confirmProblem, .cancelled) + XCTAssertTrue(world.cloud.sent(.pairingConfirmation, from: phone.deviceID).isEmpty) + guard case .code(let display) = flow.stage else { return XCTFail("the code must stay") } + XCTAssertFalse(display.confirmed) + + phone.approvalBehavior = .fail + await flow.confirm() + XCTAssertEqual(flow.confirmProblem, .failed) + XCTAssertTrue(world.cloud.sent(.pairingConfirmation, from: phone.deviceID).isEmpty) + + phone.approvalBehavior = .sign + await flow.confirm() + XCTAssertNil(flow.confirmProblem) + XCTAssertEqual(phone.approvalReasons.count, 3, "every confirmation asks again") + XCTAssertEqual(world.cloud.sent(.pairingConfirmation, from: phone.deviceID).count, 1) + } + + func testCancelWithoutAChallengeSendsAWithdrawalAndStoresNothing() async throws { + let phoneID = try world.phone.deviceID + _ = try await world.scanMacCode() + await world.model.closePairingAndWait() + XCTAssertNil(world.model.pairing) + XCTAssertEqual(world.cloud.sent(.pairingWithdrawal, from: phoneID).count, 1) + XCTAssertTrue(world.cloud.sent(.pairingCancellation, from: phoneID).isEmpty) + XCTAssertTrue(world.model.pairedMacs.isEmpty) + } + + func testCancelAfterTheCodeSendsACancellationAndTheMacEndsToo() async throws { + let phoneID = try world.phone.deviceID + let (flow, _) = try await world.reachCode() + await flow.confirm() + await world.model.closePairingAndWait() + XCTAssertEqual(world.cloud.sent(.pairingCancellation, from: phoneID).count, 1) + _ = try await world.mac.poll() + let macPhase = await world.mac.pairingPhase + XCTAssertEqual(macPhase, .ended(.cancelledByPeer)) + XCTAssertTrue(world.model.pairedMacs.isEmpty) + let macList = try await world.mac.pairedDevices() + XCTAssertTrue(macList.isEmpty) + } + + func testNoChallengeWithinSixtySecondsWithdrawsAndSaysNotToEnterACode() async throws { + let phoneID = try world.phone.deviceID + let flow = try await world.scanMacCode() + world.clock.advance(by: 61) + await flow.refresh() + XCTAssertEqual(flow.stage, .failed(.noChallenge)) + XCTAssertFalse(flow.isSessionOpen) + XCTAssertEqual(world.cloud.sent(.pairingWithdrawal, from: phoneID).count, 1) + XCTAssertTrue(world.model.pairedMacs.isEmpty) + } + + func testTheMacCancellingEndsThePairing() async throws { + let (flow, _) = try await world.reachCode() + await world.mac.cancelPairing() + await flow.refresh() + XCTAssertEqual(flow.stage, .failed(.cancelledByMac)) + XCTAssertTrue(world.model.pairedMacs.isEmpty) + } + + func testAnUnusableApprovalKeyLeadsToTheReset() async throws { + let phone = try world.phone + let (flow, _) = try await world.reachCode() + phone.isUnusable = true + await flow.confirm() + XCTAssertEqual(world.model.stage, .unusableIdentity) + XCTAssertNil(world.model.pairing) + } + + func testSessionIsOpenOnlyBetweenTheScanAndTheOutcome() async throws { + world.model.startPairing() + let flow = try XCTUnwrap(world.model.pairing) + XCTAssertFalse(flow.isSessionOpen) + await world.model.closePairingAndWait() + let (codeFlow, _) = try await world.reachCode() + XCTAssertTrue(codeFlow.isSessionOpen) + } +} + +final class PairingCodeDisplayTests: XCTestCase { + func testPrintingAStageNeverRevealsTheCode() { + let stage = PairingFlowModel.Stage.code(PairingCodeDisplay(digits: "123 456", macName: "Mac", confirmed: false)) + for text in [String(describing: stage), String(reflecting: stage), "\(stage)"] { + XCTAssertFalse(text.contains("123"), text) + XCTAssertFalse(text.contains("456"), text) + } + } +} diff --git a/iOS/AskKeyiOSKitTests/ScreenRenderTests.swift b/iOS/AskKeyiOSKitTests/ScreenRenderTests.swift new file mode 100644 index 0000000..2b3e6c2 --- /dev/null +++ b/iOS/AskKeyiOSKitTests/ScreenRenderTests.swift @@ -0,0 +1,103 @@ +import SwiftUI +import XCTest +@testable import AskKeyiOSKit + +/// Renders every screen state from the app's own SwiftUI views with fixed +/// states, in English and Simplified Chinese, for review. `build-ios.sh +/// test` passes the output directory; CI uploads it. +@MainActor +final class ScreenRenderTests: XCTestCase { + private static let languages = ["en", "zh-Hans"] + private static let mac = "Studio Mac" + private static let pairedAt = Date(timeIntervalSince1970: 1_793_000_000) + /// An iPhone screen in points; taller content makes a taller image. + private static let screenSize = CGSize(width: 393, height: 852) + + private var outputDirectory: URL { + if let path = ProcessInfo.processInfo.environment["SCREEN_RENDER_DIRECTORY"], !path.isEmpty { + return URL(fileURLWithPath: path, isDirectory: true) + } + return FileManager.default.temporaryDirectory.appendingPathComponent("AskKeyiOSScreens", isDirectory: true) + } + + private func render(_ name: String, _ screen: Screen, + file: StaticString = #filePath, line: UInt = #line) throws { + try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + var images: [Data] = [] + for language in Self.languages { + let content = screen + .frame(width: Self.screenSize.width) + .frame(minHeight: Self.screenSize.height, alignment: .top) + .background(Color(uiColor: .systemGroupedBackground)) + .environment(\.locale, Locale(identifier: language)) + .environment(\.timeZone, TimeZone(identifier: "UTC")!) + .environment(\.colorScheme, .light) + let renderer = ImageRenderer(content: content) + renderer.scale = 2 + let image = try XCTUnwrap(renderer.uiImage, "\(name) did not render", file: file, line: line) + XCTAssertGreaterThanOrEqual(image.size.height, Self.screenSize.height, file: file, line: line) + let data = try XCTUnwrap(image.pngData(), file: file, line: line) + try data.write(to: outputDirectory.appendingPathComponent("\(name)-\(language).png")) + images.append(data) + } + XCTAssertNotEqual(images[0], images[1], "\(name) looks the same in both languages", file: file, line: line) + } + + func testWelcome() throws { + try render("01-welcome", WelcomeContent(step: .intro, onContinue: {})) + try render("02-welcome-network-failure", WelcomeContent(step: .failed(.networkUnavailable), onContinue: {})) + } + + func testScanner() throws { + try render("03-scanner-camera-unavailable", + ScannerContent(camera: .unavailable, problem: nil, isSending: false, onDismissProblem: {}, + onCancel: {}) { EmptyView() }) + try render("04-scanner-expired-code", + ScannerContent(camera: .unavailable, problem: .expired, isSending: false, onDismissProblem: {}, + onCancel: {}) { EmptyView() }) + } + + func testPairingCode() throws { + let display = PairingCodeDisplay(digits: "123 456", macName: Self.mac, confirmed: false) + try render("05-pairing-no-code-yet", + PairingCodeContent(display: nil, isConfirming: false, problem: nil, onConfirm: {}, onCancel: {})) + try render("06-pairing-code", + PairingCodeContent(display: display, isConfirming: false, problem: nil, onConfirm: {}, onCancel: {})) + try render("07-pairing-face-id-cancelled", + PairingCodeContent(display: display, isConfirming: false, problem: .cancelled, onConfirm: {}, + onCancel: {})) + try render("08-pairing-confirmed", + PairingCodeContent(display: PairingCodeDisplay(digits: "123 456", macName: Self.mac, confirmed: true), + isConfirming: false, problem: nil, onConfirm: {}, onCancel: {})) + } + + func testPairingOutcome() throws { + try render("09-pairing-failed-no-challenge", + PairingResultContent(outcome: .failed(.noChallenge), onDone: {})) + try render("10-pairing-paired", PairingResultContent(outcome: .paired(macName: Self.mac), onDone: {})) + } + + func testPairedMacs() throws { + let row = PairedMacRow(id: "mac", name: Self.mac, pairedAt: Self.pairedAt, lastReachedAt: nil) + try render("11-paired-macs-one-mac", + PairedMacsContent(rows: [row], accountNotice: nil, listNotice: nil, records: [], onPair: {}, + onRemove: { _ in }, onDismissNotice: {})) + let reached = PairedMacRow(id: "mac", name: Self.mac, pairedAt: Self.pairedAt, + lastReachedAt: Self.pairedAt.addingTimeInterval(600)) + let records = [ + DeliveryRecord(recordName: "a", arrivedAt: Self.pairedAt.addingTimeInterval(300), source: .polling), + DeliveryRecord(recordName: "b", arrivedAt: Self.pairedAt.addingTimeInterval(600), source: .push), + ] + try render("12-paired-macs-reached", + PairedMacsContent(rows: [reached], accountNotice: nil, + listNotice: .removed(name: "Office Mac", macWasTold: true), records: records, + onPair: {}, onRemove: { _ in }, onDismissNotice: {})) + try render("13-paired-macs-empty-signed-out", + PairedMacsContent(rows: [], accountNotice: .signedOut, listNotice: .removedByMac(name: Self.mac), + records: [], onPair: {}, onRemove: { _ in }, onDismissNotice: {})) + } + + func testUnusableIdentity() throws { + try render("14-unusable-identity", UnusableIdentityContent(isResetting: false, resetFailed: false, onReset: {})) + } +} diff --git a/iOS/AskKeyiOSKitTests/WelcomeTests.swift b/iOS/AskKeyiOSKitTests/WelcomeTests.swift new file mode 100644 index 0000000..c479b7a --- /dev/null +++ b/iOS/AskKeyiOSKitTests/WelcomeTests.swift @@ -0,0 +1,134 @@ +import AskKeyDevices +import XCTest +@testable import AskKeyiOSKit + +@MainActor +final class WelcomeTests: XCTestCase { + private func failure(_ prepare: (TestWorld) -> Void) async -> (TestWorld, WelcomeStep) { + let world = TestWorld() + prepare(world) + await world.model.start() + XCTAssertEqual(world.model.stage, .welcome) + await world.model.runWelcomeChecks() + return (world, world.model.welcomeStep) + } + + func testLaunchWithoutIdentityShowsWelcomeAndCreatesNoTransport() async { + let world = TestWorld() + await world.model.start() + XCTAssertEqual(world.model.stage, .welcome) + XCTAssertEqual(world.model.welcomeStep, .intro) + XCTAssertTrue(world.factory.madeTransports.isEmpty) + XCTAssertEqual(world.identities.createdCount, 0) + } + + func testPassingChecksCreatesKeysListInboxAndAsksForNotifications() async throws { + let world = TestWorld() + await world.setUpPhone() + let phone = try world.phone + XCTAssertEqual(world.identities.createdCount, 1) + XCTAssertEqual(world.keychain.accounts, ["device-message-key", "device-agreement-key", "device-approval-key", + "paired-devices"]) + XCTAssertEqual(world.cloud.preparedDevices, [phone.deviceID]) + XCTAssertEqual(world.factory.madeTransports, [phone.deviceID]) + XCTAssertEqual(world.notifications.authorizationRequests, 1) + XCTAssertEqual(world.localState.deviceID, phone.deviceID.text) + XCTAssertEqual(world.localState.inboxPosition, InboxPosition(userRecordName: "_user-a", token: nil)) + XCTAssertTrue(world.model.pairedMacs.isEmpty) + } + + func testNoSecureEnclave() async { + let (world, step) = await failure { $0.identities.isSecureEnclaveAvailable = false } + XCTAssertEqual(step, .failed(.noSecureEnclave)) + XCTAssertTrue(world.factory.madeTransports.isEmpty) + } + + func testEachBiometryFailureHasItsOwnExplanation() async { + let cases: [(BiometryStatus, WelcomeFailure)] = [ + (.notEnrolled(.faceID), .biometryNotEnrolled(.faceID)), + (.notEnrolled(.touchID), .biometryNotEnrolled(.touchID)), + (.passcodeNotSet, .passcodeNotSet), + (.deniedForApp(.faceID), .biometryDenied(.faceID)), + (.unavailable, .noBiometry), + ] + for (status, expected) in cases { + let (world, step) = await failure { $0.biometry.current = status } + XCTAssertEqual(step, .failed(expected)) + XCTAssertEqual(world.identities.createdCount, 0, "no key before the checks pass") + XCTAssertTrue(world.factory.madeTransports.isEmpty, "no container before the checks pass") + } + } + + func testBuildWithoutCloudKitNeverCreatesATransport() async { + let (world, step) = await failure { $0.factory.isConfigured = false } + XCTAssertEqual(step, .failed(.cloudNotConfigured)) + XCTAssertEqual(world.identities.createdCount, 0) + XCTAssertTrue(world.factory.madeTransports.isEmpty) + } + + func testEachICloudFailureHasItsOwnExplanationAndStoresNothing() async { + let cases: [(CloudKitAccountState, WelcomeFailure)] = [ + (.noAccount, .iCloudSignedOut), + (.restricted, .iCloudRestricted), + (.temporarilyUnavailable, .iCloudUnavailable), + ] + for (state, expected) in cases { + let (world, step) = await failure { $0.cloud.accountState = state } + XCTAssertEqual(step, .failed(expected)) + XCTAssertTrue(world.keychain.accounts.isEmpty, "keys stay in memory until every check passed") + XCTAssertEqual(world.notifications.authorizationRequests, 0) + } + } + + func testKeyCreationFailure() async { + let (world, step) = await failure { $0.identities.createError = .keyCreationFailed } + XCTAssertEqual(step, .failed(.keyCreationFailed)) + XCTAssertTrue(world.keychain.accounts.isEmpty) + } + + func testKeychainFailureLeavesNoKeys() async { + let (world, step) = await failure { $0.keychain.writesFail = true } + XCTAssertEqual(step, .failed(.keychainFailed)) + XCTAssertTrue(world.keychain.accounts.isEmpty) + } + + func testNetworkFailureKeepsTheIdentityAndRetryFinishesWithTheSameKeys() async throws { + let (world, step) = await failure { $0.cloud.prepareError = .networkUnavailable } + XCTAssertEqual(step, .failed(.networkUnavailable)) + XCTAssertEqual(world.keychain.accounts.count, 4) + world.cloud.prepareError = nil + await world.model.runWelcomeChecks() + XCTAssertEqual(world.model.stage, .ready) + XCTAssertEqual(world.identities.createdCount, 1, "a retry never creates new keys") + XCTAssertEqual(world.cloud.preparedDevices, [try world.phone.deviceID]) + } + + func testOtherICloudFailuresAndPendingSubscriptions() async { + let (_, quota) = await failure { $0.cloud.prepareError = .quotaExceeded } + XCTAssertEqual(quota, .failed(.iCloudFailed)) + let (_, pending) = await failure { $0.cloud.prepareLeavesSubscriptionsPending = true } + XCTAssertEqual(pending, .failed(.iCloudFailed)) + } + + func testLaunchWithIdentityRestoresItAndSkipsWelcome() async throws { + let world = TestWorld() + await world.setUpPhone() + let relaunched = AppModel(environment: world.environment) + await relaunched.start() + XCTAssertEqual(relaunched.stage, .ready) + XCTAssertEqual(world.identities.createdCount, 1) + } + + func testRetryAfterAnUnreadableKeychainRestoresAnExistingIdentityInsteadOfReplacingIt() async throws { + let world = TestWorld() + await world.setUpPhone() + let stored = world.keychain.accounts + let relaunched = AppModel(environment: world.environment) + relaunched.stage = .welcome + relaunched.welcomeStep = .failed(.keychainFailed) + await relaunched.runWelcomeChecks() + XCTAssertEqual(relaunched.stage, .ready) + XCTAssertEqual(world.identities.createdCount, 1) + XCTAssertEqual(world.keychain.accounts, stored) + } +} diff --git a/iOS/project.yml b/iOS/project.yml new file mode 100644 index 0000000..c36d20d --- /dev/null +++ b/iOS/project.yml @@ -0,0 +1,83 @@ +# The pairing-only iPhone app (#289, ADR 0013 → iPhone app). Generate the +# Xcode project with `bash scripts/build-ios.sh`; it is never committed. +name: AskKeyiOS +options: + deploymentTarget: + iOS: "17.0" + developmentLanguage: en + createIntermediateGroups: true +packages: + AskKey: + path: .. +settings: + base: + SWIFT_VERSION: "5.0" + TARGETED_DEVICE_FAMILY: "1" + SUPPORTS_MACCATALYST: NO + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" + # The catalogs are maintained by hand; a build never rewrites them. + SWIFT_EMIT_LOC_STRINGS: NO + LOCALIZATION_PREFERS_STRING_CATALOGS: YES + ENABLE_USER_SCRIPT_SANDBOXING: YES +targets: + AskKeyiOSKit: + type: framework + platform: iOS + sources: + - path: AskKeyiOSKit + dependencies: + - package: AskKey + product: AskKeyDevices + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.sudohg.askkey.ios.kit + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_CFBundleDevelopmentRegion: en + SKIP_INSTALL: YES + DEFINES_MODULE: YES + AskKeyiOS: + type: application + platform: iOS + sources: + - path: AskKeyiOS + dependencies: + - target: AskKeyiOSKit + settings: + base: + PRODUCT_NAME: AskKey + PRODUCT_BUNDLE_IDENTIFIER: com.sudohg.askkey.ios + INFOPLIST_FILE: AskKeyiOS/Info.plist + CODE_SIGN_ENTITLEMENTS: AskKeyiOS/AskKeyiOS.entitlements + # Empty unless the Ad Hoc export of scripts/build-ios.sh sets it; the + # app creates a CloudKit container only when its Info.plist names one. + CLOUDKIT_CONTAINER_IDENTIFIER: "" + AskKeyiOSKitTests: + type: bundle.unit-test + platform: iOS + sources: + - path: AskKeyiOSKitTests + dependencies: + - target: AskKeyiOSKit + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.sudohg.askkey.ios.kit.tests + GENERATE_INFOPLIST_FILE: YES +schemes: + AskKeyiOS: + build: + targets: + AskKeyiOS: all + archive: + config: Release + AskKeyiOSKitTests: + build: + targets: + AskKeyiOSKitTests: [test] + test: + config: Debug + gatherCoverageData: false + targets: + - AskKeyiOSKitTests diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh new file mode 100755 index 0000000..5421e54 --- /dev/null +++ b/scripts/build-ios.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# The pairing-only iPhone app (#289, ADR 0013 → iPhone app). +# +# bash scripts/build-ios.sh build Simulator build, unsigned. +# bash scripts/build-ios.sh test AskKeyiOSKitTests on a simulator, unsigned, without an +# app host; writes the rendered screens to .build/ios/screens. +# ASKKEY_APPLE_TEAM_ID= bash scripts/build-ios.sh export +# Ad Hoc export for the maintainer's iPhone: automatic signing +# for that team, the CloudKit container in the Info.plist, and +# the Production container environment; then reads back the +# signed entitlements. +# +# The Xcode project is generated from iOS/project.yml with XcodeGen and never committed. No team, +# signing identity or profile is stored in the repository. +set -euo pipefail +cd "$(dirname "$0")/.." +ROOT="$(pwd -P)" +MODE="${1:-build}" +case "$MODE" in + build|test|export) ;; + *) echo 'Usage: build-ios.sh [build|test|export]' >&2; exit 2 ;; +esac + +PROJECT="$ROOT/iOS/AskKeyiOS.xcodeproj" +OUTPUT="$ROOT/.build/ios" +DERIVED="$OUTPUT/DerivedData" +CONTAINER="iCloud.com.sudohg.askkey" +BUNDLE_ID="com.sudohg.askkey.ios" +ACCESS_GROUP_SUFFIX="com.sudohg.askkey.ios.shared" + +AVAILABLE_KIB="$(df -k /System/Volumes/Data | awk 'NR==2 {print $4}')" +[[ "${AVAILABLE_KIB:-0}" -ge 83886080 ]] || { echo 'The iPhone build requires at least 80 GiB free.' >&2; exit 1; } +command -v xcodegen >/dev/null || { echo 'Install XcodeGen (brew install xcodegen) before building the iPhone app.' >&2; exit 1; } +mkdir -p "$OUTPUT" + +# Every catalog entry has English and Simplified Chinese with the same +# placeholders, every key the kit's Swift sources use is in its catalog, and +# the app's catalog holds the alert keys the AlertMessage subscription names. +python3 - "$ROOT/iOS" <<'PY' +import json, pathlib, re, sys +root = pathlib.Path(sys.argv[1]) +kit = root / 'AskKeyiOSKit/Resources/Localizable.xcstrings' +app = root / 'AskKeyiOS/Resources/Localizable.xcstrings' +placeholder = re.compile(r'%(?:\d+\$)?(?:@|lld|d)') +problems = [] +catalogs = {} +for catalog in (kit, app): + strings = json.loads(catalog.read_text(encoding='utf-8'))['strings'] + catalogs[catalog] = strings + for key, entry in strings.items(): + units = [entry.get('localizations', {}).get(language, {}).get('stringUnit', {}) for language in ('en', 'zh-Hans')] + if any(unit.get('state') != 'translated' or not unit.get('value') for unit in units): + problems.append(f'{catalog.relative_to(root)}: "{key}" needs English and Simplified Chinese') + elif len({tuple(sorted(placeholder.findall(unit['value']))) for unit in units} | {tuple(sorted(placeholder.findall(key)))}) != 1: + problems.append(f'{catalog.relative_to(root)}: "{key}" has mismatched placeholders') +literal = re.compile(r'"((?:common|welcome|scanner|pairing|list|unusable)\.[A-Za-z0-9.]+)((?: \\\([^"]*?\))*)"') +for source in sorted((root / 'AskKeyiOSKit').rglob('*.swift')): + for match in literal.finditer(source.read_text(encoding='utf-8')): + key = match.group(1) + ' %@' * match.group(2).count('\\(') + if key not in catalogs[kit]: + problems.append(f'{source.relative_to(root)}: "{key}" is not in the kit catalog') +for key in ('push.message.title', 'push.message.body'): + if key not in catalogs[app]: + problems.append(f'{app.relative_to(root)}: "{key}" is missing') +for problem in problems: + print(problem, file=sys.stderr) +sys.exit(1 if problems else 0) +PY + +xcodegen generate --spec "$ROOT/iOS/project.yml" --quiet + +pick_simulator() { + xcrun simctl list devices available -j | python3 -c ' +import json, re, sys +devices = json.load(sys.stdin)["devices"] +def version(runtime): + match = re.search(r"iOS-(\d+)-(\d+)", runtime) + return tuple(map(int, match.groups())) if match else None +runtimes = sorted((r for r in devices if version(r)), key=version, reverse=True) +for runtime in runtimes: + for device in devices[runtime]: + if device["name"].startswith("iPhone"): + print(device["udid"], device["state"]) + sys.exit(0) +sys.exit("No available iPhone simulator.")' +} + +case "$MODE" in +build) + xcodebuild build -project "$PROJECT" -scheme AskKeyiOS -configuration Debug \ + -destination 'generic/platform=iOS Simulator' -derivedDataPath "$DERIVED" CODE_SIGNING_ALLOWED=NO -quiet + APP="$DERIVED/Build/Products/Debug-iphonesimulator/AskKey.app" + test -x "$APP/AskKey" && test -d "$APP/Frameworks/AskKeyiOSKit.framework" + echo "Built $(basename "$APP") for the iOS Simulator" + ;; +test) + read -r UDID STATE < <(pick_simulator) + SCREENS="$OUTPUT/screens" + rm -rf "$SCREENS" + mkdir -p "$SCREENS" + status=0 + # TEST_RUNNER_ variables reach the test process without the prefix. + TEST_RUNNER_SCREEN_RENDER_DIRECTORY="$SCREENS" xcodebuild test -project "$PROJECT" -scheme AskKeyiOSKitTests \ + -destination "platform=iOS Simulator,id=$UDID" -derivedDataPath "$DERIVED" CODE_SIGNING_ALLOWED=NO || status=$? + [[ "$STATE" == Booted ]] || xcrun simctl shutdown "$UDID" >/dev/null 2>&1 || true + [[ "$status" -eq 0 ]] || exit "$status" + COUNT="$(find "$SCREENS" -name '*.png' | wc -l | tr -d ' ')" + [[ "$COUNT" -ge 8 ]] || { echo "Expected at least 8 rendered screens, found $COUNT." >&2; exit 1; } + echo "Rendered $COUNT screens into ${SCREENS#"$ROOT"/}" + ;; +export) + TEAM="${ASKKEY_APPLE_TEAM_ID:-}" + [[ "$TEAM" =~ ^[A-Z0-9]{10}$ ]] || { echo 'Set ASKKEY_APPLE_TEAM_ID to the ten-character Apple Developer team ID.' >&2; exit 1; } + ARCHIVE="$OUTPUT/AskKeyiOS.xcarchive" + EXPORT="$OUTPUT/export" + OPTIONS="$OUTPUT/ExportOptions.plist" + rm -rf "$ARCHIVE" "$EXPORT" "$OPTIONS" + xcodebuild archive -project "$PROJECT" -scheme AskKeyiOS -configuration Release -destination 'generic/platform=iOS' \ + -archivePath "$ARCHIVE" -derivedDataPath "$DERIVED" -allowProvisioningUpdates \ + DEVELOPMENT_TEAM="$TEAM" CODE_SIGN_STYLE=Automatic CLOUDKIT_CONTAINER_IDENTIFIER="$CONTAINER" + /usr/libexec/PlistBuddy -c 'Add :method string release-testing' -c "Add :teamID string $TEAM" \ + -c 'Add :signingStyle string automatic' -c 'Add :iCloudContainerEnvironment string Production' \ + -c 'Add :thinning string ' "$OPTIONS" >/dev/null + xcodebuild -exportArchive -archivePath "$ARCHIVE" -exportPath "$EXPORT" -exportOptionsPlist "$OPTIONS" \ + -allowProvisioningUpdates + IPA="$(find "$EXPORT" -maxdepth 1 -name '*.ipa' | head -n 1)" + [[ -n "$IPA" ]] || { echo 'The export produced no .ipa.' >&2; exit 1; } + CHECK="$(mktemp -d)" + trap 'rm -rf "$CHECK"' EXIT + ditto -x -k "$IPA" "$CHECK" + APP="$(find "$CHECK/Payload" -maxdepth 1 -name '*.app' | head -n 1)" + codesign --verify --strict "$APP" + codesign -d --entitlements - --xml "$APP" > "$CHECK/entitlements.plist" 2>/dev/null + python3 - "$CHECK/entitlements.plist" "$APP/Info.plist" "$TEAM" "$BUNDLE_ID" "$CONTAINER" "$ACCESS_GROUP_SUFFIX" <<'PY' +import plistlib, sys +entitlements_path, info_path, team, bundle_id, container, group_suffix = sys.argv[1:] +entitlements = plistlib.load(open(entitlements_path, 'rb')) +info = plistlib.load(open(info_path, 'rb')) +group = f'{team}.{group_suffix}' +expected = { + 'application-identifier': f'{team}.{bundle_id}', + 'com.apple.developer.team-identifier': team, + 'aps-environment': 'production', + 'com.apple.developer.icloud-container-environment': 'Production', + 'com.apple.developer.icloud-container-identifiers': [container], + 'com.apple.developer.icloud-services': ['CloudKit'], + 'keychain-access-groups': [group], +} +problems = [f'entitlement {key} is {entitlements.get(key)!r}, expected {value!r}' + for key, value in expected.items() if entitlements.get(key) != value] +if entitlements.get('get-task-allow'): + problems.append('entitlement get-task-allow is set') +for key, value in {'CFBundleIdentifier': bundle_id, 'CloudKitContainerIdentifier': container, + 'KeychainAccessGroup': group}.items(): + if info.get(key) != value: + problems.append(f'Info.plist {key} is {info.get(key)!r}, expected {value!r}') +for problem in problems: + print(problem, file=sys.stderr) +if problems: + sys.exit(1) +for key in sorted(expected): + print(f'{key} = {entitlements[key]}') +PY + echo "Exported ${IPA#"$ROOT"/}" + ;; +esac diff --git a/scripts/check_hygiene.py b/scripts/check_hygiene.py index a847f1f..dbcbe7c 100644 --- a/scripts/check_hygiene.py +++ b/scripts/check_hygiene.py @@ -25,6 +25,9 @@ "site/404.html", # Bilingual not-found page of the website (#160). "docs/glossary.md", # Chinese UI terms alongside English glossary prose (#67). "Sources/AskKeyAppKit/Resources/zh-Hans.lproj/InfoPlist.strings", # Localized product name. + "iOS/AskKeyiOSKit/Resources/Localizable.xcstrings", # English and Simplified Chinese iPhone UI catalog (#289). + "iOS/AskKeyiOS/Resources/Localizable.xcstrings", # iPhone push alert text, which must live in the app bundle (#289). + "iOS/AskKeyiOS/Resources/zh-Hans.lproj/InfoPlist.strings", # Localized iPhone app name and usage descriptions (#289). "Tests/AskKeyAppTests/AgentClientConnectionPolicyTests.swift", # Chinese client connection errors. "Tests/AskKeyAppTests/AppLanguageCatalogTests.swift", # Chinese catalog and bundle-name assertions. "Tests/AskKeyAppTests/AppLanguageExperienceTests.swift", # Chinese language switching and persistence. From cce62b0805bd85e853759f8dcabe0da1e4aaf674 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Tue, 10 Nov 2026 14:24:40 +0800 Subject: [PATCH 2/4] Address the first review of the iPhone app - A keychain read that fails at launch shows the welcome screen's keychain message with Try Again instead of offering the reset. - Pushes that arrive before the identity is restored (opening a notification cold-launches the app) are kept and become hints once it is, so the delivery check is recorded as found through the push. - A pairing request that finishes after Cancel is cancelled only when no newer pairing replaced it. - When the account check cannot reach iCloud, for example before the wireless-data prompt is answered, the welcome and list texts give the connection and wireless-data guidance. - build-ios.sh picks a simulator whose runtime the selected Xcode supports. --- iOS/AskKeyiOSKit/App/AppModel+Inbox.swift | 21 +++++++++++++++- iOS/AskKeyiOSKit/App/AppModel.swift | 15 +++++++++++ .../Pairing/PairingFlowModel.swift | 5 ++-- .../Resources/Localizable.xcstrings | 8 +++--- iOS/AskKeyiOSKitTests/Fakes/FakeSystem.swift | 12 ++++++++- iOS/AskKeyiOSKitTests/Fakes/TestWorld.swift | 19 ++++++++++++++ .../IdentityResetTests.swift | 14 +++++++++++ iOS/AskKeyiOSKitTests/LocalizationTests.swift | 20 ++++----------- iOS/AskKeyiOSKitTests/PairedMacsTests.swift | 25 +++++++++++++++++++ scripts/build-ios.sh | 8 ++++-- 10 files changed, 122 insertions(+), 25 deletions(-) diff --git a/iOS/AskKeyiOSKit/App/AppModel+Inbox.swift b/iOS/AskKeyiOSKit/App/AppModel+Inbox.swift index d70b0b4..12f4a96 100644 --- a/iOS/AskKeyiOSKit/App/AppModel+Inbox.swift +++ b/iOS/AskKeyiOSKit/App/AppModel+Inbox.swift @@ -6,6 +6,8 @@ extension AppModel { static let deliveryRecordLimit = 50 /// How long the app looks for the message a push pointed to. static let pushHintLifetime = MessageKind.deliveryCheckLifetime + 5 * 60 + /// The most pushes kept while the app launches. + static let earlyPushLimit = 20 /// How long a reset waits for CloudKit to delete the inbox. static let inboxRemovalTimeout: TimeInterval = 10 @@ -21,7 +23,14 @@ extension AppModel { /// only a hint: the app fetches its inbox and acts on what it verifies, /// never on the notification's text, category or action. public func receivedPush(userInfo: [AnyHashable: Any], arrivedAt: Date, notificationIdentifier: String?) async { - guard let services else { return } + guard let services else { + // Opening a notification launched the app, and the identity is + // not restored yet: keep the push until it is. + if stage == .starting, earlyPushes.count < Self.earlyPushLimit { + earlyPushes.append(EarlyPush(userInfo: userInfo, arrivedAt: arrivedAt, identifier: notificationIdentifier)) + } + return + } guard let hint = PushHintReader.hint(userInfo: userInfo, arrivedAt: arrivedAt, notificationIdentifier: notificationIdentifier, for: services.deviceID) else { return } @@ -29,6 +38,16 @@ extension AppModel { await processInbox() } + /// Turns the pushes kept during launch into hints for this device. + func adoptEarlyPushes(for device: DeviceID) { + let pushes = earlyPushes + earlyPushes = [] + remember(pushes.compactMap { + PushHintReader.hint(userInfo: $0.userInfo, arrivedAt: $0.arrivedAt, notificationIdentifier: $0.identifier, + for: device) + }) + } + func remember(_ hints: [PushHint]) { let now = environment.clock.now() var stale: [String] = [] diff --git a/iOS/AskKeyiOSKit/App/AppModel.swift b/iOS/AskKeyiOSKit/App/AppModel.swift index d45efa1..cc5a934 100644 --- a/iOS/AskKeyiOSKit/App/AppModel.swift +++ b/iOS/AskKeyiOSKit/App/AppModel.swift @@ -24,6 +24,8 @@ public final class AppModel { @ObservationIgnored var pendingSetup: PendingSetup? @ObservationIgnored var accountChecked = false @ObservationIgnored var pushHints: [String: PushHint] = [:] + /// Pushes that arrived before the identity was restored at launch. + @ObservationIgnored var earlyPushes: [EarlyPush] = [] /// Macs removed on this iPhone, so their disappearance is not reported /// as a removal by the Mac. @ObservationIgnored var locallyRemoved: Set = [] @@ -65,6 +67,12 @@ public final class AppModel { do { identity = try environment.identities.restore(blobs) _ = try listStore.loadPairedDeviceList() + } catch let error as KeychainError { + // Reading failed; nothing says the stored items are bad. + AppLog.failure("Reading the paired-device list", error) + stage = .welcome + welcomeStep = .failed(.keychainFailed) + return } catch { AppLog.failure("Restoring the identity", error) stage = .unusableIdentity @@ -85,6 +93,7 @@ public final class AppModel { func activate(_ services: DeviceServices) async { self.services = services + adoptEarlyPushes(for: services.deviceID) stage = .ready watchAccount() await refreshList() @@ -122,6 +131,12 @@ public final class AppModel { } } + struct EarlyPush { + let userInfo: [AnyHashable: Any] + let arrivedAt: Date + let identifier: String? + } + final class PendingSetup { let identity: any DeviceIdentity let blobs: DeviceIdentityKeyBlobs diff --git a/iOS/AskKeyiOSKit/Pairing/PairingFlowModel.swift b/iOS/AskKeyiOSKit/Pairing/PairingFlowModel.swift index ef8b9e7..b8498da 100644 --- a/iOS/AskKeyiOSKit/Pairing/PairingFlowModel.swift +++ b/iOS/AskKeyiOSKit/Pairing/PairingFlowModel.swift @@ -79,9 +79,10 @@ public final class PairingFlowModel: Identifiable { do { try await messenger.joinPairing(scannedText: text, displayName: deviceName) joined = true - // Cancel was chosen while the request was being created. + // Cancel was chosen while the request was being created. A newer + // pairing that started meanwhile already replaced this session. guard !finished else { - await messenger.cancelPairing() + if host?.pairing == nil { await messenger.cancelPairing() } return } stage = .waitingForCode diff --git a/iOS/AskKeyiOSKit/Resources/Localizable.xcstrings b/iOS/AskKeyiOSKit/Resources/Localizable.xcstrings index 8b5fa55..302bbb4 100644 --- a/iOS/AskKeyiOSKit/Resources/Localizable.xcstrings +++ b/iOS/AskKeyiOSKit/Resources/Localizable.xcstrings @@ -92,13 +92,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "iCloud isn't available right now, so your Macs can't reach this iPhone." + "value": "Ask Key can't reach iCloud right now, so your Macs can't reach this iPhone. Check the connection and that Ask Key may use wireless data." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "iCloud 暂时不可用,Mac 无法联系这台 iPhone。" + "value": "请旨暂时无法连接 iCloud,Mac 也无法联系这台 iPhone。请检查网络,并确认已允许请旨使用无线数据。" } } } @@ -1299,13 +1299,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "iCloud isn't available right now. Check your Apple Account in Settings, then try again." + "value": "Ask Key couldn't reach iCloud. Check the connection. If your iPhone asked about wireless data, allow WLAN & Cellular Data in Settings → Ask Key → Wireless Data. Also check your Apple Account in Settings, then try again." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "iCloud 暂时不可用。请在“设置”中检查你的 Apple 账户,然后重试。" + "value": "请旨无法连接 iCloud。请检查网络。如果 iPhone 询问过无线数据权限,请在“设置”→“请旨”→“无线数据”中选择“无线局域网与蜂窝网络”。另请在“设置”中检查你的 Apple 账户,然后重试。" } } } diff --git a/iOS/AskKeyiOSKitTests/Fakes/FakeSystem.swift b/iOS/AskKeyiOSKitTests/Fakes/FakeSystem.swift index 9ed61d5..d0efcbc 100644 --- a/iOS/AskKeyiOSKitTests/Fakes/FakeSystem.swift +++ b/iOS/AskKeyiOSKitTests/Fakes/FakeSystem.swift @@ -6,16 +6,26 @@ final class InMemoryKeychain: SecureItemStore, @unchecked Sendable { private let lock = NSLock() private var items: [String: Data] = [:] private var failWrites = false + private var failReads: Set = [] var accounts: Set { lock.withLock { Set(items.keys) } } + /// Reading these accounts fails, as when the keychain cannot answer. + var unreadableAccounts: Set { + get { lock.withLock { failReads } } + set { lock.withLock { failReads = newValue } } + } + var writesFail: Bool { get { lock.withLock { failWrites } } set { lock.withLock { failWrites = newValue } } } func data(for account: String) throws -> Data? { - lock.withLock { items[account] } + try lock.withLock { + if failReads.contains(account) { throw KeychainError(status: errSecInteractionNotAllowed) } + return items[account] + } } func setData(_ data: Data, for account: String) throws { diff --git a/iOS/AskKeyiOSKitTests/Fakes/TestWorld.swift b/iOS/AskKeyiOSKitTests/Fakes/TestWorld.swift index 4f7a03b..ad25a00 100644 --- a/iOS/AskKeyiOSKitTests/Fakes/TestWorld.swift +++ b/iOS/AskKeyiOSKitTests/Fakes/TestWorld.swift @@ -39,6 +39,25 @@ final class TestWorld { get throws { try XCTUnwrap(identities.latest) } } + /// A CloudKit query notification's payload, as the system delivers it. + nonisolated static func pushPayload(subscription: String, zone: String, record: String) -> [AnyHashable: Any] { + [ + "aps": ["alert": ["title-loc-key": "push.message.title", "loc-key": "push.message.body"], "mutable-content": 1], + "ck": [ + "ce": 2, + "cid": "iCloud.com.sudohg.askkey", + "nid": UUID().uuidString, + "qry": ["dbs": 1, "fo": 1, "rid": record, "sid": subscription, "zid": zone, "zoid": "_defaultOwner"], + ] as [String: Any], + ] + } + + /// The payload of the alert push for a message in `device`'s inbox. + nonisolated static func alertPush(for device: DeviceID, record: String) -> [AnyHashable: Any] { + let zone = CloudKitInboxLayout.zoneName(for: device) + return pushPayload(subscription: zone + "_alert", zone: zone, record: record) + } + /// Launches the app and passes the welcome checks. func setUpPhone(file: StaticString = #filePath, line: UInt = #line) async { await model.start() diff --git a/iOS/AskKeyiOSKitTests/IdentityResetTests.swift b/iOS/AskKeyiOSKitTests/IdentityResetTests.swift index 6a2828b..34f13b1 100644 --- a/iOS/AskKeyiOSKitTests/IdentityResetTests.swift +++ b/iOS/AskKeyiOSKitTests/IdentityResetTests.swift @@ -66,6 +66,20 @@ final class IdentityResetTests: XCTestCase { XCTAssertEqual(relaunched.stage, .unusableIdentity) } + func testAKeychainThatCannotBeReadIsNotTreatedAsUnusable() async throws { + let world = TestWorld() + await world.setUpPhone() + world.keychain.unreadableAccounts = ["paired-devices"] + let relaunched = AppModel(environment: world.environment) + await relaunched.start() + XCTAssertEqual(relaunched.stage, .welcome) + XCTAssertEqual(relaunched.welcomeStep, .failed(.keychainFailed)) + world.keychain.unreadableAccounts = [] + await relaunched.runWelcomeChecks() + XCTAssertEqual(relaunched.stage, .ready) + XCTAssertEqual(world.identities.createdCount, 1) + } + func testResetOnlyFromTheUnusableScreen() async throws { let world = TestWorld() await world.setUpPhone() diff --git a/iOS/AskKeyiOSKitTests/LocalizationTests.swift b/iOS/AskKeyiOSKitTests/LocalizationTests.swift index c57a535..31d80ca 100644 --- a/iOS/AskKeyiOSKitTests/LocalizationTests.swift +++ b/iOS/AskKeyiOSKitTests/LocalizationTests.swift @@ -28,6 +28,9 @@ final class LocalizationTests: XCTestCase { XCTAssertTrue(try XCTUnwrap(english["scanner.instruction"]).contains("on your own Mac")) XCTAssertTrue(try XCTUnwrap(english["scanner.instruction"]).contains("Never scan a pairing code that someone sends you")) XCTAssertTrue(try XCTUnwrap(english["pairing.failed.noChallenge"]).contains("don't enter any code")) + // Before the wireless-data prompt is answered, the account check itself fails. + XCTAssertTrue(try XCTUnwrap(english["welcome.failure.iCloudUnavailable"]).contains("Wireless Data")) + XCTAssertTrue(try XCTUnwrap(english["welcome.failure.networkUnavailable"]).contains("Wireless Data")) } func testAlertKeysMatchTheSubscription() { @@ -40,21 +43,8 @@ final class PushHintReaderTests: XCTestCase { private let device = DeviceID(rawValue: Data(repeating: 7, count: 16))! private let other = DeviceID(rawValue: Data(repeating: 9, count: 16))! - private func payload(subscription: String, zone: String, record: String) -> [AnyHashable: Any] { - [ - "aps": ["alert": ["title-loc-key": "push.message.title", "loc-key": "push.message.body"], "mutable-content": 1], - "ck": [ - "ce": 2, - "cid": "iCloud.com.sudohg.askkey", - "nid": UUID().uuidString, - "qry": ["dbs": 1, "fo": 1, "rid": record, "sid": subscription, "zid": zone, "zoid": "_defaultOwner"], - ] as [String: Any], - ] - } - func testAQueryNotificationForThisInboxIsAHint() throws { - let zone = CloudKitInboxLayout.zoneName(for: device) - let info = payload(subscription: zone + "_alert", zone: zone, record: "abc") + let info = TestWorld.alertPush(for: device, record: "abc") let arrival = Date(timeIntervalSince1970: 1_800_000_000) let hint = PushHintReader.hint(userInfo: info, arrivedAt: arrival, notificationIdentifier: "n", for: device) XCTAssertEqual(hint, PushHint(recordName: "abc", arrivedAt: arrival, notificationIdentifier: "n")) @@ -62,7 +52,7 @@ final class PushHintReaderTests: XCTestCase { func testAnotherDevicesPushOrAPlainAlertIsNotAHint() { let zone = CloudKitInboxLayout.zoneName(for: other) - let foreign = payload(subscription: zone + "_alert", zone: zone, record: "abc") + let foreign = TestWorld.pushPayload(subscription: zone + "_alert", zone: zone, record: "abc") XCTAssertNil(PushHintReader.hint(userInfo: foreign, arrivedAt: Date(), notificationIdentifier: nil, for: device)) let plain: [AnyHashable: Any] = ["aps": ["alert": "Enter 123 456 on your Mac"]] XCTAssertNil(PushHintReader.hint(userInfo: plain, arrivedAt: Date(), notificationIdentifier: nil, for: device)) diff --git a/iOS/AskKeyiOSKitTests/PairedMacsTests.swift b/iOS/AskKeyiOSKitTests/PairedMacsTests.swift index 1a95e10..ee8712b 100644 --- a/iOS/AskKeyiOSKitTests/PairedMacsTests.swift +++ b/iOS/AskKeyiOSKitTests/PairedMacsTests.swift @@ -65,6 +65,31 @@ final class PairedMacsTests: XCTestCase { XCTAssertEqual(world.cloud.sent(.deliveryCheckReply, from: phoneID).count, 1) } + func testOpeningANotificationThatLaunchesTheAppRecordsItAsPush() async throws { + let phoneID = try world.phone.deviceID + let check = try await world.mac.sendDeliveryCheck(to: phoneID) + let arrival = world.clock.now().addingTimeInterval(2) + world.clock.advance(by: 20) + let launched = AppModel(environment: world.environment) + // The system reports the opened notification before the identity is restored. + await launched.receivedPush(userInfo: TestWorld.alertPush(for: phoneID, record: check.text), arrivedAt: arrival, + notificationIdentifier: "opened") + await launched.start() + await launched.becameActive() + XCTAssertEqual(launched.deliveryRecords, [DeliveryRecord(recordName: check.text, arrivedAt: arrival, source: .push)]) + XCTAssertEqual(world.cloud.sent(.deliveryCheckReply, from: phoneID).count, 1) + } + + func testPushReceivedWhileRunningIsRecordedAsPush() async throws { + let phoneID = try world.phone.deviceID + let check = try await world.mac.sendDeliveryCheck(to: phoneID) + let arrival = world.clock.now() + await world.model.receivedPush(userInfo: TestWorld.alertPush(for: phoneID, record: check.text), arrivedAt: arrival, + notificationIdentifier: "front") + XCTAssertEqual(world.model.deliveryRecords, + [DeliveryRecord(recordName: check.text, arrivedAt: arrival, source: .push)]) + } + func testPushReceivedWhileRunningForAnotherDeviceIsIgnored() async throws { let phoneID = try world.phone.deviceID let check = try await world.mac.sendDeliveryCheck(to: phoneID) diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh index 5421e54..453963f 100755 --- a/scripts/build-ios.sh +++ b/scripts/build-ios.sh @@ -69,20 +69,24 @@ PY xcodegen generate --spec "$ROOT/iOS/project.yml" --quiet +# The newest iPhone simulator whose runtime's major version the selected Xcode's SDK supports. pick_simulator() { + local sdk + sdk="$(xcrun --sdk iphonesimulator --show-sdk-version)" xcrun simctl list devices available -j | python3 -c ' import json, re, sys devices = json.load(sys.stdin)["devices"] +sdk_major = int(sys.argv[1].split(".")[0]) def version(runtime): match = re.search(r"iOS-(\d+)-(\d+)", runtime) return tuple(map(int, match.groups())) if match else None -runtimes = sorted((r for r in devices if version(r)), key=version, reverse=True) +runtimes = sorted((r for r in devices if version(r) and version(r)[0] <= sdk_major), key=version, reverse=True) for runtime in runtimes: for device in devices[runtime]: if device["name"].startswith("iPhone"): print(device["udid"], device["state"]) sys.exit(0) -sys.exit("No available iPhone simulator.")' +sys.exit("No available iPhone simulator for this Xcode.")' "$sdk" } case "$MODE" in From c85c7649aefccd8ea663b20c057daa165ef0eeb5 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Tue, 10 Nov 2026 14:32:42 +0800 Subject: [PATCH 3/4] Address the second review of the iPhone app - A pairing request that finishes after Cancel is also cancelled when a newer pairing sheet is open but has not sent its own request yet. - Test that a build without CloudKitContainerIdentifier never creates a transport. --- iOS/AskKeyiOSKit/Pairing/PairingFlowModel.swift | 5 +++-- iOS/AskKeyiOSKitTests/WelcomeTests.swift | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/iOS/AskKeyiOSKit/Pairing/PairingFlowModel.swift b/iOS/AskKeyiOSKit/Pairing/PairingFlowModel.swift index b8498da..81e694f 100644 --- a/iOS/AskKeyiOSKit/Pairing/PairingFlowModel.swift +++ b/iOS/AskKeyiOSKit/Pairing/PairingFlowModel.swift @@ -80,9 +80,10 @@ public final class PairingFlowModel: Identifiable { try await messenger.joinPairing(scannedText: text, displayName: deviceName) joined = true // Cancel was chosen while the request was being created. A newer - // pairing that started meanwhile already replaced this session. + // pairing whose own request is under way replaces (and cancels) + // this session; otherwise this session is still current. guard !finished else { - if host?.pairing == nil { await messenger.cancelPairing() } + if host?.pairing?.isSessionOpen != true { await messenger.cancelPairing() } return } stage = .waitingForCode diff --git a/iOS/AskKeyiOSKitTests/WelcomeTests.swift b/iOS/AskKeyiOSKitTests/WelcomeTests.swift index c479b7a..f9517c4 100644 --- a/iOS/AskKeyiOSKitTests/WelcomeTests.swift +++ b/iOS/AskKeyiOSKitTests/WelcomeTests.swift @@ -132,3 +132,13 @@ final class WelcomeTests: XCTestCase { XCTAssertEqual(world.keychain.accounts, stored) } } + +final class CloudKitGateTests: XCTestCase { + /// A bundle without `CloudKitContainerIdentifier`, like every build but + /// the Ad Hoc export, never creates a container. + func testABuildWithoutTheContainerKeyNeverCreatesATransport() { + let factory = CloudKitTransportFactory(bundle: Bundle(for: CloudKitGateTests.self)) + XCTAssertFalse(factory.isConfigured) + XCTAssertNil(factory.makeTransport(for: DeviceID(rawValue: Data(repeating: 1, count: 16))!)) + } +} From 561899948661b16f425b01622713e16094ee3e0d Mon Sep 17 00:00:00 2001 From: sudoHG Date: Tue, 10 Nov 2026 15:26:02 +0800 Subject: [PATCH 4/4] Call the QR code a QR code on the iPhone scanner "Pairing code" means only the six digits the iPhone shows. The scanner's instruction and its invalid, expired, foreign and key-conflict messages now speak of the QR code in English and Chinese, and the Chinese for "Not reached yet" reads as the Mac not having reached this iPhone yet. A test keeps the scanner texts from calling the QR code a pairing code. --- .../Resources/Localizable.xcstrings | 22 +++++++++---------- iOS/AskKeyiOSKitTests/LocalizationTests.swift | 13 ++++++++++- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/iOS/AskKeyiOSKit/Resources/Localizable.xcstrings b/iOS/AskKeyiOSKit/Resources/Localizable.xcstrings index 302bbb4..44e2b1e 100644 --- a/iOS/AskKeyiOSKit/Resources/Localizable.xcstrings +++ b/iOS/AskKeyiOSKit/Resources/Localizable.xcstrings @@ -200,7 +200,7 @@ "zh-Hans": { "stringUnit": { "state": "translated", - "value": "尚未联系" + "value": "还没联系上" } } } @@ -857,13 +857,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Scan only the QR code that Ask Key shows on your own Mac. Never scan a pairing code that someone sends you." + "value": "Scan only the QR code that Ask Key shows on your own Mac. Never scan a QR code that someone sends you." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "只扫描你自己的 Mac 上请旨显示的二维码。别人发给你的配对码,一律不要扫。" + "value": "只扫描你自己的 Mac 上请旨显示的二维码。别人发给你的二维码,一律不要扫。" } } } @@ -908,13 +908,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "This pairing code has expired. On your Mac, start pairing again and scan the new code." + "value": "This QR code has expired. On your Mac, start pairing again and scan the new QR code." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "这个配对码已过期。请在 Mac 上重新开始配对,然后扫描新的二维码。" + "value": "这个二维码已过期。请在 Mac 上重新开始配对,然后扫描新的二维码。" } } } @@ -942,13 +942,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "This pairing code can't be read. On your Mac, start pairing again and scan the new code." + "value": "This QR code can't be read. On your Mac, start pairing again and scan the new QR code." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "无法读取这个配对码。请在 Mac 上重新开始配对,然后扫描新的二维码。" + "value": "无法读取这个二维码。请在 Mac 上重新开始配对,然后扫描新的二维码。" } } } @@ -959,13 +959,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "This pairing code can't be used with this iPhone." + "value": "This QR code can't be used with this iPhone." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "这个配对码不能用于这台 iPhone。" + "value": "这个二维码不能用于这台 iPhone。" } } } @@ -976,13 +976,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "This isn't an Ask Key pairing code. Scan the QR code in Ask Key on your own Mac." + "value": "This isn't an Ask Key pairing QR code. Scan the QR code in Ask Key on your own Mac." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "这不是请旨的配对码。请扫描你自己的 Mac 上请旨显示的二维码。" + "value": "这不是请旨的配对二维码。请扫描你自己的 Mac 上请旨显示的二维码。" } } } diff --git a/iOS/AskKeyiOSKitTests/LocalizationTests.swift b/iOS/AskKeyiOSKitTests/LocalizationTests.swift index 31d80ca..05f16df 100644 --- a/iOS/AskKeyiOSKitTests/LocalizationTests.swift +++ b/iOS/AskKeyiOSKitTests/LocalizationTests.swift @@ -26,13 +26,24 @@ final class LocalizationTests: XCTestCase { let english = try table("en") XCTAssertTrue(try XCTUnwrap(english["pairing.code.onlyHere"]).contains("never sends it in a notification or a message")) XCTAssertTrue(try XCTUnwrap(english["scanner.instruction"]).contains("on your own Mac")) - XCTAssertTrue(try XCTUnwrap(english["scanner.instruction"]).contains("Never scan a pairing code that someone sends you")) + XCTAssertTrue(try XCTUnwrap(english["scanner.instruction"]).contains("Never scan a QR code that someone sends you")) XCTAssertTrue(try XCTUnwrap(english["pairing.failed.noChallenge"]).contains("don't enter any code")) // Before the wireless-data prompt is answered, the account check itself fails. XCTAssertTrue(try XCTUnwrap(english["welcome.failure.iCloudUnavailable"]).contains("Wireless Data")) XCTAssertTrue(try XCTUnwrap(english["welcome.failure.networkUnavailable"]).contains("Wireless Data")) } + /// "Pairing code" means only the six digits; the scanner speaks of the QR code. + func testScannerTextsNeverCallTheQRCodeAPairingCode() throws { + let english = try table("en") + let chinese = try table("zh-Hans") + let chinesePairingCode = "\u{914D}\u{5BF9}\u{7801}" + for key in english.keys where key.hasPrefix("scanner.") { + XCTAssertFalse(try XCTUnwrap(english[key]).localizedCaseInsensitiveContains("pairing code"), key) + XCTAssertFalse(try XCTUnwrap(chinese[key]).contains(chinesePairingCode), key) + } + } + func testAlertKeysMatchTheSubscription() { XCTAssertEqual(CloudKitInboxLayout.alertTitleLocalizationKey, "push.message.title") XCTAssertEqual(CloudKitInboxLayout.alertBodyLocalizationKey, "push.message.body")