diff --git a/Sources/AskKeyAppKit/AppLanguage.swift b/Sources/AskKeyAppKit/AppLanguage.swift index 706c7a3..40a95e8 100644 --- a/Sources/AskKeyAppKit/AppLanguage.swift +++ b/Sources/AskKeyAppKit/AppLanguage.swift @@ -187,6 +187,7 @@ enum AppLanguage { "Expired without decision", "Open Ask Key to review pending requests.", "Disable system authentication for read approvals", + "Pair a device with this Mac", "Ask Key could not save an access record. Credential operations continue, and this warning will remain until recording succeeds.", "Ask Key could not prepare Agent access. Open the app to review the vault state.", "Ask Key could not apply this decision. Open Pending requests to retry or reject it.", diff --git a/Sources/AskKeyAppKit/Devices/DeviceCoordinator+Pairing.swift b/Sources/AskKeyAppKit/Devices/DeviceCoordinator+Pairing.swift index 63d279b..f690bb3 100644 --- a/Sources/AskKeyAppKit/Devices/DeviceCoordinator+Pairing.swift +++ b/Sources/AskKeyAppKit/Devices/DeviceCoordinator+Pairing.swift @@ -11,24 +11,36 @@ extension DeviceCoordinator { /// session is open. The open sheet keeps the management session from /// expiring for idleness until the session ends or the sheet closes. package func startPairing() async { - guard environment.backend != nil, pairing?.isActive != true else { return } + // T6 TEMP #298 trace lines in this function, revert before merge. + DevicesPairingTrace.log("startPairing: entry backend=\(environment.backend != nil) pairing=\(DevicesPairingTrace.describe(pairing)) " + + "services=\(services != nil) availability=\(availability)") + guard environment.backend != nil, pairing?.isActive != true else { + DevicesPairingTrace.log("startPairing: returned at guard backend/active session") + return + } if services == nil, availability == .loading || availability == .storageUnavailable { _ = openStoredServices() + DevicesPairingTrace.log("startPairing: opened stored services, availability=\(availability)") } switch availability { case .ready: break case .secureEnclaveUnavailable: + DevicesPairingTrace.log("startPairing: failed, secureEnclaveUnavailable") return failPairing(.secureEnclaveUnavailable) case .identityUnusable, .listRefused: + DevicesPairingTrace.log("startPairing: failed, identityUnusable/listRefused") return failPairing(.identityUnusable) case .storageUnavailable, .loading, .notConfigured: + DevicesPairingTrace.log("startPairing: failed, storageUnavailable (\(availability))") return failPairing(.storageUnavailable) } guard managementSessionIsOpen() else { + DevicesPairingTrace.log("startPairing: failed, managementRequired") pairing = .failed(.managementRequired) return } + DevicesPairingTrace.log("startPairing: preparing") pairingGeneration += 1 let generation = pairingGeneration pairing = .preparing @@ -36,38 +48,58 @@ extension DeviceCoordinator { pairingExpiresAt = nil acquireExpiryHold() do { - guard try await openServicesForPairing(generation: generation), generation == pairingGeneration, - let services else { return } + let opened = try await openServicesForPairing(generation: generation) + guard opened, generation == pairingGeneration, let services else { + DevicesPairingTrace.log("startPairing: returned after opening services, opened=\(opened) " + + "current=\(generation == pairingGeneration) services=\(self.services != nil) pairing=\(DevicesPairingTrace.describe(pairing))") + return + } if account != .available { await refreshAccount() } - guard generation == pairingGeneration else { return } - guard account == .available else { return failPairing(.accountUnavailable(account)) } + guard generation == pairingGeneration else { + DevicesPairingTrace.log("startPairing: returned, superseded after the account check") + return + } + guard account == .available else { + DevicesPairingTrace.log("startPairing: failed, account \(account)") + return failPairing(.accountUnavailable(account)) + } do { _ = try await services.inbox.prepareInbox() } catch { + DevicesPairingTrace.log("startPairing: inbox preparation threw \(error)") guard generation == pairingGeneration else { return } return failPairing(.iCloudUnavailable) } - guard generation == pairingGeneration, self.services === services else { return } + guard generation == pairingGeneration, self.services === services else { + DevicesPairingTrace.log("startPairing: returned, superseded after the inbox") + return + } let qr = try await services.messenger.startPairing(displayName: environment.displayName()) guard generation == pairingGeneration, self.services === services else { + DevicesPairingTrace.log("startPairing: returned, superseded after the messenger") await services.messenger.cancelPairing() return } pairing = .showingQRCode(text: qr.text, expiresAt: qr.qrExpiry) pairingExpiresAt = qr.qrExpiry.addingTimeInterval(Self.sessionExtension) startPairingPolling() + DevicesPairingTrace.log("startPairing: showing the QR code") } catch DeviceIdentityError.secureEnclaveUnavailable { + DevicesPairingTrace.log("startPairing: threw secureEnclaveUnavailable") guard generation == pairingGeneration else { return } availability = .secureEnclaveUnavailable failPairing(.secureEnclaveUnavailable) } catch DeviceIdentityError.unusable { + DevicesPairingTrace.log("startPairing: threw unusable") guard generation == pairingGeneration else { return } identityBecameUnusable() failPairing(.identityUnusable) } catch DeviceIdentityError.keyCreationFailed { + DevicesPairingTrace.log("startPairing: threw keyCreationFailed") guard generation == pairingGeneration else { return } failPairing(.keyCreationFailed) } catch { + DevicesPairingTrace.log("startPairing: threw \(error)") guard generation == pairingGeneration else { return } failPairing(.storageUnavailable) } @@ -141,7 +173,7 @@ extension DeviceCoordinator { let generation = pairingGeneration pairingFeedback = nil do { - try await services.messenger.confirmPairing(approvalReason: Vault.devicePairingReason) + try await services.messenger.confirmPairing(approvalReason: ManagementAuthenticationAction.pairDevice.reasonKey) } catch DeviceIdentityError.authenticationCancelled { if generation == pairingGeneration { pairingFeedback = .authenticationCancelled } } catch DeviceIdentityError.authenticationFailed, DeviceIdentityError.authenticationUnavailable { diff --git a/Sources/AskKeyAppKit/ManagementAuthenticationProcess.swift b/Sources/AskKeyAppKit/ManagementAuthenticationProcess.swift index 4d22610..cc440b9 100644 --- a/Sources/AskKeyAppKit/ManagementAuthenticationProcess.swift +++ b/Sources/AskKeyAppKit/ManagementAuthenticationProcess.swift @@ -8,7 +8,7 @@ enum ManagementAuthenticationAction: CaseIterable { case clearAccessRecords, eraseLibrary, revealFrozenFile case approveRead, approveWrite, permanentlyDelete, replaceImportedCredential case disableReadAuthentication, changeTimedAllowanceEnd, legacyRelease, legacyUnlock - case exportBackup, importBackup + case exportBackup, importBackup, pairDevice var reasonKey: String { switch self { @@ -29,6 +29,7 @@ enum ManagementAuthenticationAction: CaseIterable { case .legacyUnlock: "Unlock the AskKey vault for %@" case .exportBackup: CredentialManagementCopy.exportBackupReason case .importBackup: CredentialManagementCopy.importBackupReason + case .pairDevice: "Pair a device with this Mac" } } diff --git a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings index a134439..3cd96ac 100644 --- a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings +++ b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings @@ -16823,6 +16823,686 @@ } } } + }, + "Devices are unavailable because Ask Key can't read this Mac's device keys right now.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Devices are unavailable because Ask Key can't read this Mac's device keys right now." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "请旨暂时无法读取这台 Mac 的设备密钥,设备功能不可用。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Devices are unavailable because Ask Key can't read this Mac's device keys right now.!!]" } } + } + }, + "Pair Ask Key on your iPhone with this Mac. They exchange encrypted messages through iCloud, so both must use the same Apple Account.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Pair Ask Key on your iPhone with this Mac. They exchange encrypted messages through iCloud, so both must use the same Apple Account." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "将 iPhone 上的请旨与这台 Mac 配对。两台设备通过 iCloud 交换加密消息,所以必须登录同一个 Apple 账户。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Pair Ask Key on your iPhone with this Mac. They exchange encrypted messages through iCloud, so both must use the same Apple Account.!!]" } } + } + }, + "iCloud is signed out, so paired devices can't be reached. To pair, sign in with the same Apple Account as your iPhone.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "iCloud is signed out, so paired devices can't be reached. To pair, sign in with the same Apple Account as your iPhone." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "iCloud 已退出登录,无法联系已配对的设备。要配对,请用与 iPhone 相同的 Apple 账户登录。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!iCloud is signed out, so paired devices can't be reached. To pair, sign in with the same Apple Account as your iPhone.!!]" } } + } + }, + "iCloud is restricted on this Mac, for example by Screen Time or a device management profile, so devices can't be reached.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "iCloud is restricted on this Mac, for example by Screen Time or a device management profile, so devices can't be reached." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "这台 Mac 的 iCloud 受到限制(例如“屏幕使用时间”或设备管理描述文件),因此无法联系设备。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!iCloud is restricted on this Mac, for example by Screen Time or a device management profile, so devices can't be reached.!!]" } } + } + }, + "Ask Key can't reach iCloud right now, so devices can't be reached. Check the connection.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Ask Key can't reach iCloud right now, so devices can't be reached. Check the connection." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "请旨暂时无法连接 iCloud,因此无法联系设备。请检查网络。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Ask Key can't reach iCloud right now, so devices can't be reached. Check the connection.!!]" } } + } + }, + "This Mac now uses a different Apple Account than the one these devices were paired in, so they can't be reached. Sign back in, or remove them and pair again.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This Mac now uses a different Apple Account than the one these devices were paired in, so they can't be reached. Sign back in, or remove them and pair again." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "这台 Mac 现在登录的 Apple 账户与配对时不同,因此无法联系这些设备。请重新登录原来的账户,或移除这些设备后重新配对。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This Mac now uses a different Apple Account than the one these devices were paired in, so they can't be reached. Sign back in, or remove them and pair again.!!]" } } + } + }, + "%1$@ · Paired %2$@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%1$@ · Paired %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ · 配对于 %2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ · Paired %2$@!!]" } } + } + }, + "Not reached yet": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Not reached yet" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "还没联系上" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Not reached yet!!]" } } + } + }, + "Last reached %@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Last reached %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "上次联系:%@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Last reached %@!!]" } } + } + }, + "Can't be reached": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Can't be reached" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "无法联系" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Can't be reached!!]" } } + } + }, + "Delivery check sent": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Delivery check sent" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已发送送达检查" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Delivery check sent!!]" } } + } + }, + "Delivery check answered": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Delivery check answered" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "送达检查已回应" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Delivery check answered!!]" } } + } + }, + "Delivery check got no reply": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Delivery check got no reply" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "送达检查没有回应" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Delivery check got no reply!!]" } } + } + }, + "Credential management is locked. Unlock it, then try again.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Credential management is locked. Unlock it, then try again." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证管理已锁定。请解锁后重试。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Credential management is locked. Unlock it, then try again.!!]" } } + } + }, + "This device is no longer paired.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This device is no longer paired." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "这台设备已不在配对列表中。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This device is no longer paired.!!]" } } + } + }, + "The delivery check couldn't be sent. Check the connection, then try again.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The delivery check couldn't be sent. Check the connection, then try again." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "无法发送送达检查。请检查网络后重试。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The delivery check couldn't be sent. Check the connection, then try again.!!]" } } + } + }, + "The device was removed here, but Ask Key couldn't tell it. Remove this Mac on that device too.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The device was removed here, but Ask Key couldn't tell it. Remove this Mac on that device too." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已在这台 Mac 上移除该设备,但没能通知它。请也在那台设备上移除这台 Mac。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The device was removed here, but Ask Key couldn't tell it. Remove this Mac on that device too.!!]" } } + } + }, + "That didn't work. Try again.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "That didn't work. Try again." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "操作没有完成,请重试。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!That didn't work. Try again.!!]" } } + } + }, + "This Mac stops trusting “%@” at once. Ask Key tries to tell the device so it can remove this Mac. If it still lists this Mac, remove this Mac on that device too.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This Mac stops trusting “%@” at once. Ask Key tries to tell the device so it can remove this Mac. If it still lists this Mac, remove this Mac on that device too." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "这台 Mac 将立即停止信任“%@”。请旨会尝试通知对方移除这台 Mac;如果对方仍列出这台 Mac,请在那台设备上手动移除。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This Mac stops trusting “%@” at once. Ask Key tries to tell the device so it can remove this Mac. If it still lists this Mac, remove this Mac on that device too.!!]" } } + } + }, + "Preparing…": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Preparing…" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "正在准备…" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Preparing…!!]" } } + } + }, + "Scan with Your iPhone": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Scan with Your iPhone" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "用 iPhone 扫描" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Scan with Your iPhone!!]" } } + } + }, + "In Ask Key on your iPhone, tap Pair a Mac, then scan this QR code.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "In Ask Key on your iPhone, tap Pair a Mac, then scan this QR code." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "在 iPhone 上的请旨中轻点“配对 Mac”,然后扫描这个二维码。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!In Ask Key on your iPhone, tap Pair a Mac, then scan this QR code.!!]" } } + } + }, + "Scan only the QR code in this window. Never scan a QR code that someone sends you.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Scan only the QR code in this window. Never scan a QR code that someone sends you." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "只扫描这个窗口中的二维码。别人发给你的二维码,一律不要扫。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Scan only the QR code in this window. Never scan a QR code that someone sends you.!!]" } } + } + }, + "Waiting for the iPhone": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Waiting for the iPhone" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "正在等待 iPhone" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Waiting for the iPhone!!]" } } + } + }, + "The iPhone shows a pairing code once it has answered, and this window then asks for it.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The iPhone shows a pairing code once it has answered, and this window then asks for it." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "iPhone 回应后会显示配对码,这个窗口随后会请你输入。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The iPhone shows a pairing code once it has answered, and this window then asks for it.!!]" } } + } + }, + "If the iPhone has shown a code for more than about 10 seconds while this window still waits, cancel and pair again.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "If the iPhone has shown a code for more than about 10 seconds while this window still waits, cancel and pair again." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "如果 iPhone 已显示配对码超过约 10 秒,而这个窗口仍在等待,请取消并重新配对。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!If the iPhone has shown a code for more than about 10 seconds while this window still waits, cancel and pair again.!!]" } } + } + }, + "Enter the Pairing Code": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Enter the Pairing Code" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "输入配对码" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Enter the Pairing Code!!]" } } + } + }, + "Waiting for system authentication…": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Waiting for system authentication…" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "正在等待系统验证…" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Waiting for system authentication…!!]" } } + } + }, + "The code matches. Confirm to finish pairing; Ask Key asks for system authentication.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The code matches. Confirm to finish pairing; Ask Key asks for system authentication." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "配对码正确。确认后即可完成配对,请旨会要求系统验证。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The code matches. Confirm to finish pairing; Ask Key asks for system authentication.!!]" } } + } + }, + "Confirm on the iPhone": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Confirm on the iPhone" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "在 iPhone 上确认" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Confirm on the iPhone!!]" } } + } + }, + "Confirmed on this Mac. Tap Confirm Pairing on the iPhone to finish.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Confirmed on this Mac. Tap Confirm Pairing on the iPhone to finish." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已在这台 Mac 上确认。请在 iPhone 上轻点“确认配对”以完成配对。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Confirmed on this Mac. Tap Confirm Pairing on the iPhone to finish.!!]" } } + } + }, + "Paired": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Paired" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已配对" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Paired!!]" } } + } + }, + "This Mac and “%@” are paired. Ask Key sent a delivery check; until the device answers, it shows as “Not reached yet”.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This Mac and “%@” are paired. Ask Key sent a delivery check; until the device answers, it shows as “Not reached yet”." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "这台 Mac 已与“%@”配对。请旨已发送送达检查,在对方回应之前,它会显示为“还没联系上”。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This Mac and “%@” are paired. Ask Key sent a delivery check; until the device answers, it shows as “Not reached yet”.!!]" } } + } + }, + "Pairing Ended": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Pairing Ended" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "配对已结束" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Pairing Ended!!]" } } + } + }, + "Pairing Couldn't Start": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Pairing Couldn't Start" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "无法开始配对" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Pairing Couldn't Start!!]" } } + } + }, + "Enter the code shown on the iPhone's Ask Key pairing screen. Never enter a code from a notification or a message. If that screen shows no code, cancel.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Enter the code shown on the iPhone's Ask Key pairing screen. Never enter a code from a notification or a message. If that screen shows no code, cancel." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "请输入 iPhone 上请旨配对页面显示的配对码。切勿输入通知或消息里的任何数字。如果那个页面没有显示配对码,请取消。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Enter the code shown on the iPhone's Ask Key pairing screen. Never enter a code from a notification or a message. If that screen shows no code, cancel.!!]" } } + } + }, + "Enter the six half-width digits shown on the iPhone.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Enter the six half-width digits shown on the iPhone." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "请输入 iPhone 上显示的 6 位半角数字。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Enter the six half-width digits shown on the iPhone.!!]" } } + } + }, + "System authentication was cancelled. Nothing was sent; you can confirm again.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "System authentication was cancelled. Nothing was sent; you can confirm again." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "系统验证已取消,没有发送任何内容。你可以再次确认。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!System authentication was cancelled. Nothing was sent; you can confirm again.!!]" } } + } + }, + "System authentication didn't succeed. Nothing was sent; you can confirm again.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "System authentication didn't succeed. Nothing was sent; you can confirm again." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "系统验证未通过,没有发送任何内容。你可以再次确认。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!System authentication didn't succeed. Nothing was sent; you can confirm again.!!]" } } + } + }, + "Pairing was cancelled. Nothing was stored.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Pairing was cancelled. Nothing was stored." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "配对已取消,没有保存任何内容。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Pairing was cancelled. Nothing was stored.!!]" } } + } + }, + "The code didn't match three times, so this pairing ended. Nothing was stored. Pair again to get a new QR code.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The code didn't match three times, so this pairing ended. Nothing was stored. Pair again to get a new QR code." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "配对码 3 次都不正确,这次配对已结束,没有保存任何内容。请重新配对以获取新的二维码。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The code didn't match three times, so this pairing ended. Nothing was stored. Pair again to get a new QR code.!!]" } } + } + }, + "The iPhone's answer didn't match its request, so this pairing ended. Nothing was stored. Pair again, and scan only the QR code in this window.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The iPhone's answer didn't match its request, so this pairing ended. Nothing was stored. Pair again, and scan only the QR code in this window." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "iPhone 的回应与它的请求不符,这次配对已结束,没有保存任何内容。请重新配对,并且只扫描这个窗口中的二维码。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The iPhone's answer didn't match its request, so this pairing ended. Nothing was stored. Pair again, and scan only the QR code in this window.!!]" } } + } + }, + "Pairing was cancelled on the iPhone. Nothing was stored.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Pairing was cancelled on the iPhone. Nothing was stored." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "配对已在 iPhone 上取消,没有保存任何内容。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Pairing was cancelled on the iPhone. Nothing was stored.!!]" } } + } + }, + "The iPhone cancelled this pairing after it finished, so the device was removed again.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The iPhone cancelled this pairing after it finished, so the device was removed again." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "iPhone 在配对完成后取消了这次配对,因此这台设备已被移除。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The iPhone cancelled this pairing after it finished, so the device was removed again.!!]" } } + } + }, + "A second device answered this QR code, so this pairing ended. Nothing was stored. Pair again, and make sure nobody else can see the QR code.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "A second device answered this QR code, so this pairing ended. Nothing was stored. Pair again, and make sure nobody else can see the QR code." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "有第二台设备回应了这个二维码,这次配对已结束,没有保存任何内容。请重新配对,并确保别人看不到二维码。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!A second device answered this QR code, so this pairing ended. Nothing was stored. Pair again, and make sure nobody else can see the QR code.!!]" } } + } + }, + "The pairing session expired. Nothing was stored. Pair again to get a new QR code.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The pairing session expired. Nothing was stored. Pair again to get a new QR code." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "配对已超时,没有保存任何内容。请重新配对以获取新的二维码。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The pairing session expired. Nothing was stored. Pair again to get a new QR code.!!]" } } + } + }, + "8 devices are already paired, the most Ask Key allows. Nothing was stored. Remove one, then pair again.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "8 devices are already paired, the most Ask Key allows. Nothing was stored. Remove one, then pair again." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已配对 8 台设备,达到请旨的上限,没有保存任何内容。请先移除一台,再重新配对。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!8 devices are already paired, the most Ask Key allows. Nothing was stored. Remove one, then pair again.!!]" } } + } + }, + "This device's keys conflict with a device that is already paired. Nothing was stored.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This device's keys conflict with a device that is already paired. Nothing was stored." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "这台设备的密钥与已配对的设备冲突,没有保存任何内容。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This device's keys conflict with a device that is already paired. Nothing was stored.!!]" } } + } + }, + "The device was removed while pairing, so this pairing ended. Nothing was stored.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The device was removed while pairing, so this pairing ended. Nothing was stored." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "配对过程中这台设备被移除,这次配对已结束,没有保存任何内容。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The device was removed while pairing, so this pairing ended. Nothing was stored.!!]" } } + } + }, + "This Mac's device keys no longer work. Nothing was stored. Reset the device identity in Settings, then pair again.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This Mac's device keys no longer work. Nothing was stored. Reset the device identity in Settings, then pair again." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "这台 Mac 的设备密钥已失效,没有保存任何内容。请在“设置”中重置设备身份,然后重新配对。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This Mac's device keys no longer work. Nothing was stored. Reset the device identity in Settings, then pair again.!!]" } } + } + }, + "Unlock credential management to pair a device.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Unlock credential management to pair a device." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "请先解锁凭证管理,再配对设备。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Unlock credential management to pair a device.!!]" } } + } + }, + "This Mac has no Secure Enclave, so it can't keep device keys safe and can't pair.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This Mac has no Secure Enclave, so it can't keep device keys safe and can't pair." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "这台 Mac 没有安全隔区,无法安全地保存设备密钥,因此不能配对。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This Mac has no Secure Enclave, so it can't keep device keys safe and can't pair.!!]" } } + } + }, + "Ask Key couldn't prepare its iCloud inbox. Check the connection, then try again.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Ask Key couldn't prepare its iCloud inbox. Check the connection, then try again." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "请旨没能准备好 iCloud 收件箱。请检查网络后重试。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Ask Key couldn't prepare its iCloud inbox. Check the connection, then try again.!!]" } } + } + }, + "This Mac's device keys no longer work, for example after Migration Assistant or a restore. Reset the device identity, then pair your devices again.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This Mac's device keys no longer work, for example after Migration Assistant or a restore. Reset the device identity, then pair your devices again." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "这台 Mac 的设备密钥已失效,例如使用“迁移助理”或从备份恢复之后。请重置设备身份,然后重新配对设备。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This Mac's device keys no longer work, for example after Migration Assistant or a restore. Reset the device identity, then pair your devices again.!!]" } } + } + }, + "Ask Key couldn't read or save this Mac's device keys. Try again.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Ask Key couldn't read or save this Mac's device keys. Try again." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "请旨无法读取或保存这台 Mac 的设备密钥。请重试。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Ask Key couldn't read or save this Mac's device keys. Try again.!!]" } } + } + }, + "The Secure Enclave didn't create the keys. Try again.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The Secure Enclave didn't create the keys. Try again." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "安全隔区没有生成密钥。请重试。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The Secure Enclave didn't create the keys. Try again.!!]" } } + } + }, + "Devices": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Devices" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "设备" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Devices!!]" } } + } + }, + "Pairing": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Pairing" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "配对" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Pairing!!]" } } + } + }, + "Reset Device Identity…": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Reset Device Identity…" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "重置设备身份…" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Reset Device Identity…!!]" } } + } + }, + "Pair a Device…": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Pair a Device…" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "配对设备…" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Pair a Device…!!]" } } + } + }, + "Delete this Mac's device keys and its list of paired devices? Then remove this Mac on each paired device and pair again. This can't be undone.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Delete this Mac's device keys and its list of paired devices? Then remove this Mac on each paired device and pair again. This can't be undone." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "删除这台 Mac 的设备密钥和已配对设备列表?之后请在每台已配对的设备上移除这台 Mac,再重新配对。此操作无法撤销。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Delete this Mac's device keys and its list of paired devices? Then remove this Mac on each paired device and pair again. This can't be undone.!!]" } } + } + }, + "Reset": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Reset" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "重置" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Reset!!]" } } + } + }, + "Rename": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Rename" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "重命名" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Rename!!]" } } + } + }, + "Test Delivery": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Test Delivery" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "测试送达" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Test Delivery!!]" } } + } + }, + "This QR code has expired. Pair again to get a new one.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This QR code has expired. Pair again to get a new one." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "这个二维码已过期。请重新配对以获取新的二维码。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This QR code has expired. Pair again to get a new one.!!]" } } + } + }, + "QR code for pairing": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "QR code for pairing" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "配对用的二维码" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!QR code for pairing!!]" } } + } + }, + "Device that scanned the QR code": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Device that scanned the QR code" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "扫描了二维码的设备" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Device that scanned the QR code!!]" } } + } + }, + "Pairing code": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Pairing code" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "配对码" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Pairing code!!]" } } + } + }, + "6 digits": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "6 digits" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "6 位数字" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!6 digits!!]" } } + } + }, + "Pairing code shown on the iPhone": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Pairing code shown on the iPhone" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "iPhone 上显示的配对码" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Pairing code shown on the iPhone!!]" } } + } + }, + "Close": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Close" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "关闭" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Close!!]" } } + } + }, + "Confirm": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Confirm" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "确认" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Confirm!!]" } } + } + }, + "Pair Again": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Pair Again" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "重新配对" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Pair Again!!]" } } + } + }, + "The list of paired devices failed its integrity check, so Ask Key won't use it. Reset the device identity, then pair your devices again.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The list of paired devices failed its integrity check, so Ask Key won't use it. Reset the device identity, then pair your devices again." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已配对设备列表未通过完整性校验,请旨不会使用它。请重置设备身份,然后重新配对设备。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The list of paired devices failed its integrity check, so Ask Key won't use it. Reset the device identity, then pair your devices again.!!]" } } + } + }, + "8 devices are paired, the most Ask Key allows. Remove one to pair another.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "8 devices are paired, the most Ask Key allows. Remove one to pair another." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已配对 8 台设备,达到请旨的上限。请先移除一台,再配对新的设备。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!8 devices are paired, the most Ask Key allows. Remove one to pair another.!!]" } } + } + }, + "That code doesn't match. %lld attempts left.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "That code doesn't match. %lld attempts left." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "配对码不正确,还可以再试 %lld 次。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!That code doesn't match. %lld attempts left.!!]" } } + } + }, + "That code doesn't match. %lld attempt left.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "That code doesn't match. %lld attempt left." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "配对码不正确,还可以再试 %lld 次。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!That code doesn't match. %lld attempt left.!!]" } } + } + }, + "Pair a device with this Mac": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Pair a device with this Mac" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "确认将设备与这台 Mac 配对" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Pair a device with this Mac!!]" } } + } + }, + "Ask Key found a device-message-key item outside login.keychain-db or duplicate device-message-key items. Quit Ask Key. In Keychain Access, remove the Ask Key device-message-key items that are not in the login keychain, then reopen Ask Key. If Devices then reports that this Mac's device identity is unusable, reset it and pair your devices again. Do not remove vault-key items.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Ask Key found a device-message-key item outside login.keychain-db or duplicate device-message-key items. Quit Ask Key. In Keychain Access, remove the Ask Key device-message-key items that are not in the login keychain, then reopen Ask Key. If Devices then reports that this Mac's device identity is unusable, reset it and pair your devices again. Do not remove vault-key items." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "请旨发现 login.keychain-db 之外的 device-message-key 项目或重复的 device-message-key 项目。请退出请旨,在“钥匙串访问”中移除不在登录钥匙串里的请旨 device-message-key 项目,然后重新打开请旨。如果“设备”随后提示这台 Mac 的设备身份不可用,请重置设备身份并重新配对设备。请勿移除 vault-key 项目。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Ask Key found a device-message-key item outside login.keychain-db or duplicate device-message-key items. Quit Ask Key. In Keychain Access, remove the Ask Key device-message-key items that are not in the login keychain, then reopen Ask Key. If Devices then reports that this Mac's device identity is unusable, reset it and pair your devices again. Do not remove vault-key items.!!]" } } + } + }, + "Ask Key found a device-agreement-key item outside login.keychain-db or duplicate device-agreement-key items. Quit Ask Key. In Keychain Access, remove the Ask Key device-agreement-key items that are not in the login keychain, then reopen Ask Key. If Devices then reports that this Mac's device identity is unusable, reset it and pair your devices again. Do not remove vault-key items.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Ask Key found a device-agreement-key item outside login.keychain-db or duplicate device-agreement-key items. Quit Ask Key. In Keychain Access, remove the Ask Key device-agreement-key items that are not in the login keychain, then reopen Ask Key. If Devices then reports that this Mac's device identity is unusable, reset it and pair your devices again. Do not remove vault-key items." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "请旨发现 login.keychain-db 之外的 device-agreement-key 项目或重复的 device-agreement-key 项目。请退出请旨,在“钥匙串访问”中移除不在登录钥匙串里的请旨 device-agreement-key 项目,然后重新打开请旨。如果“设备”随后提示这台 Mac 的设备身份不可用,请重置设备身份并重新配对设备。请勿移除 vault-key 项目。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Ask Key found a device-agreement-key item outside login.keychain-db or duplicate device-agreement-key items. Quit Ask Key. In Keychain Access, remove the Ask Key device-agreement-key items that are not in the login keychain, then reopen Ask Key. If Devices then reports that this Mac's device identity is unusable, reset it and pair your devices again. Do not remove vault-key items.!!]" } } + } + }, + "Ask Key found a device-approval-key item outside login.keychain-db or duplicate device-approval-key items. Quit Ask Key. In Keychain Access, remove the Ask Key device-approval-key items that are not in the login keychain, then reopen Ask Key. If Devices then reports that this Mac's device identity is unusable, reset it and pair your devices again. Do not remove vault-key items.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Ask Key found a device-approval-key item outside login.keychain-db or duplicate device-approval-key items. Quit Ask Key. In Keychain Access, remove the Ask Key device-approval-key items that are not in the login keychain, then reopen Ask Key. If Devices then reports that this Mac's device identity is unusable, reset it and pair your devices again. Do not remove vault-key items." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "请旨发现 login.keychain-db 之外的 device-approval-key 项目或重复的 device-approval-key 项目。请退出请旨,在“钥匙串访问”中移除不在登录钥匙串里的请旨 device-approval-key 项目,然后重新打开请旨。如果“设备”随后提示这台 Mac 的设备身份不可用,请重置设备身份并重新配对设备。请勿移除 vault-key 项目。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Ask Key found a device-approval-key item outside login.keychain-db or duplicate device-approval-key items. Quit Ask Key. In Keychain Access, remove the Ask Key device-approval-key items that are not in the login keychain, then reopen Ask Key. If Devices then reports that this Mac's device identity is unusable, reset it and pair your devices again. Do not remove vault-key items.!!]" } } + } + }, + "Ask Key found a paired-devices item outside login.keychain-db or duplicate paired-devices items. Quit Ask Key. In Keychain Access, remove the conflicting Ask Key paired-devices items, then reopen Ask Key and pair your devices again. Do not remove vault-key items.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Ask Key found a paired-devices item outside login.keychain-db or duplicate paired-devices items. Quit Ask Key. In Keychain Access, remove the conflicting Ask Key paired-devices items, then reopen Ask Key and pair your devices again. Do not remove vault-key items." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "请旨发现 login.keychain-db 之外的 paired-devices 项目或重复的 paired-devices 项目。请退出请旨,在“钥匙串访问”中移除请旨的冲突 paired-devices 项目,然后重新打开请旨并重新配对设备。请勿移除 vault-key 项目。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Ask Key found a paired-devices item outside login.keychain-db or duplicate paired-devices items. Quit Ask Key. In Keychain Access, remove the conflicting Ask Key paired-devices items, then reopen Ask Key and pair your devices again. Do not remove vault-key items.!!]" } } + } + }, + "push.message.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Ask Key" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "请旨" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Ask Key!!]" } } + } + }, + "push.message.body": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "A paired device sent a message." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "一台已配对的设备发来一条消息。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!A paired device sent a message.!!]" } } + } + }, + "Confirm Pairing": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Confirm Pairing" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "确认配对" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Confirm Pairing!!]" } } + } } }, "version": "1.0" diff --git a/Sources/AskKeyAppKit/VaultViewModel+Devices.swift b/Sources/AskKeyAppKit/VaultViewModel+Devices.swift new file mode 100644 index 0000000..973e108 --- /dev/null +++ b/Sources/AskKeyAppKit/VaultViewModel+Devices.swift @@ -0,0 +1,18 @@ +import Foundation + +extension VaultViewModel { + /// The model behind Settings → Devices (ADR 0013 → Mac App), over the + /// App's one device coordinator. It lives as long as that coordinator, + /// not as long as a view: Settings is rebuilt on every visit, and the + /// section only receives this model. Nil in processes without device + /// services; like a build without CloudKit, they show no Devices section. + static var devicesSettingsModel: DevicesSettingsModel? { + guard let coordinator = DeviceServicesHost.coordinator else { return nil } + if let devicesModel, devicesModel.coordinator === coordinator { return devicesModel } + let model = DevicesSettingsModel(coordinator: coordinator) + devicesModel = model + return model + } + + private static var devicesModel: DevicesSettingsModel? +} diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift b/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift new file mode 100644 index 0000000..844008a --- /dev/null +++ b/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift @@ -0,0 +1,197 @@ +import AskKeyDevices +import SwiftUI + +/// The **Pair a Device…** sheet (ADR 0013 → Pairing): the QR code, then the +/// device that scanned it, then the code the iPhone shows, then the result. +/// It never shows the Mac's code: no state it reads carries one. +struct DevicePairingSheet: View { + @Bindable var model: DevicesSettingsModel + /// The last state, kept while the sheet animates away after the session closed. + @State private var shown: PairingSheetPresentation? + @FocusState private var codeFocused: Bool + + var body: some View { + let _ = AppLanguage.store.resolved + let sheet = model.sheet ?? shown + Group { + if let sheet { + BackupSheetLayout(title: sheet.title, message: sheet.message) { + content(sheet) + } actions: { + actions(sheet) + } + .controlSize(.large) + } + } + .onAppear { shown = model.sheet } + .onChange(of: model.sheet) { _, new in + if let new { shown = new } + } + } + + @ViewBuilder + private func content(_ sheet: PairingSheetPresentation) -> some View { + VStack(alignment: .leading, spacing: Theme.Spacing.lg) { + switch sheet.stage { + case .preparing: + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity) + case .qrCode(let text, let expiresAt): + qrCode(text: text, expiresAt: expiresAt) + default: + EmptyView() + } + if let peer = sheet.peer { + peerCard(peer, isWaiting: sheet.stage == .waitingForIPhone + || sheet.stage == .waitingForIPhoneConfirmation) + } + if sheet.stage == .codeEntry(accepted: false) { + codeField + } + if let feedback = sheet.feedback { + Text(feedback) + .font(Theme.Fonts.secondary) + .foregroundStyle(sheet.feedbackIsWarning ? Theme.warning : Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("devices-pairing-feedback") + } + if let notice = sheet.notice { + BackupNotice(text: notice) + } + } + } + + private func qrCode(text: String, expiresAt: Date) -> some View { + TimelineView(.periodic(from: .now, by: 1)) { context in + let expired = context.date >= expiresAt + VStack(spacing: Theme.Spacing.sm) { + if expired { + Text(appLocalized("This QR code has expired. Pair again to get a new one.")) + .font(Theme.Fonts.body) + .foregroundStyle(Theme.warning) + .frame(maxWidth: .infinity, minHeight: 120) + } else if let image = model.qrImage(for: text) { + Image(nsImage: image) + .interpolation(.none) + .resizable() + .frame(width: 184, height: 184) + .padding(Theme.Spacing.md) + .background(Color.white, in: .rect(cornerRadius: Theme.Radius.control)) + .overlay(RoundedRectangle(cornerRadius: Theme.Radius.control).stroke(Theme.separator)) + // The label names the image only; the payload holds the pairing secret. + .accessibilityLabel(appLocalized("QR code for pairing")) + .accessibilityIdentifier("devices-pairing-qr") + Text(appLocalizedFormat("Expires in %@", FrozenCountdown.format(deadline: expiresAt, now: context.date))) + .font(Theme.Fonts.secondary.monospacedDigit()) + .foregroundStyle(Theme.textSecondary) + } + } + .frame(maxWidth: .infinity) + } + } + + private func peerCard(_ peer: PairingPeer, isWaiting: Bool) -> some View { + HStack(alignment: .center, spacing: Theme.Spacing.md) { + Image(systemName: DevicesPresentation.platformSymbol(peer.platform)) + .font(Theme.Icon.inlineStatus) + .foregroundStyle(Theme.textSecondary) + .frame(width: 24) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(appLocalized("Device that scanned the QR code")) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.textTertiary) + Text(peer.displayName) + .font(Theme.Fonts.body.weight(.semibold)) + .foregroundStyle(Theme.text) + .lineLimit(1) + .truncationMode(.middle) + Text(PairingSheetPresentation.peerPlatform(peer)) + .font(Theme.Fonts.secondary) + .foregroundStyle(Theme.textSecondary) + } + Spacer(minLength: 0) + if isWaiting { + ProgressView() + .controlSize(.small) + .accessibilityIdentifier("devices-pairing-waiting") + } + } + .padding(Theme.Spacing.md) + .background(Theme.surface, in: .rect(cornerRadius: Theme.Radius.group)) + .overlay(RoundedRectangle(cornerRadius: Theme.Radius.group).stroke(Theme.separator)) + } + + /// The person's entry of the iPhone's code. Not a one-time-code field and + /// no autofill or completion: the code must come from the iPhone's + /// pairing screen. The entry is never cut short. + private var codeField: some View { + CredentialFormField(appLocalized("Pairing code")) { + TextField(appLocalized("6 digits"), text: $model.codeEntry) + .textFieldStyle(.roundedBorder) + .font(Theme.Fonts.headline.monospacedDigit()) + .textContentType(nil) + .autocorrectionDisabled() + .frame(width: 180) + .focused($codeFocused) + .onSubmit { Task { await model.confirm() } } + .onAppear { codeFocused = true } + .accessibilityLabel(appLocalized("Pairing code shown on the iPhone")) + .accessibilityIdentifier("devices-pairing-code") + } + } + + @ViewBuilder + private func actions(_ sheet: PairingSheetPresentation) -> some View { + Spacer(minLength: 0) + switch sheet.stage { + case .paired: + Button(appLocalized("Done")) { Task { await model.closeSheet() } } + .buttonStyle(.borderedProminent) + .tint(Theme.accent) + .keyboardShortcut(.defaultAction) + .accessibilityIdentifier("devices-pairing-done") + case .ended(let canPairAgain): + Button(appLocalized("Close")) { Task { await model.closeSheet() } } + .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("devices-pairing-close") + if canPairAgain { pairAgainButton } + case .failed: + Button(appLocalized("Close")) { Task { await model.closeSheet() } } + .keyboardShortcut(.defaultAction) + .accessibilityIdentifier("devices-pairing-close") + case .codeEntry: + cancelButton + Button(appLocalized("Confirm")) { Task { await model.confirm() } } + .buttonStyle(.borderedProminent) + .tint(Theme.accent) + .keyboardShortcut(.defaultAction) + .disabled(!model.canConfirm) + .accessibilityIdentifier("devices-pairing-confirm") + case .qrCode(_, let expiresAt): + TimelineView(.periodic(from: .now, by: 1)) { context in + HStack(spacing: Theme.Spacing.sm) { + cancelButton + if context.date >= expiresAt { pairAgainButton } + } + } + case .preparing, .waitingForIPhone, .waitingForIPhoneConfirmation: + cancelButton + } + } + + private var cancelButton: some View { + Button(appLocalized("Cancel")) { Task { await model.closeSheet() } } + .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("devices-pairing-cancel") + } + + private var pairAgainButton: some View { + Button(appLocalized("Pair Again")) { Task { await model.pairAgain() } } + .buttonStyle(.borderedProminent) + .tint(Theme.accent) + .keyboardShortcut(.defaultAction) + .accessibilityIdentifier("devices-pairing-again") + } +} diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesPresentation.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesPresentation.swift new file mode 100644 index 0000000..085ce87 --- /dev/null +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesPresentation.swift @@ -0,0 +1,150 @@ +import AskKeyDevices +import Foundation + +/// The text Settings → Devices shows for the coordinator's state (ADR 0013 → +/// Mac App). Nothing here receives or derives the pairing code: the +/// coordinator never exposes it, so no label or accessibility text can carry it. +enum DevicesPresentation { + /// A line under the section's pairing row: the default explanation, or why + /// pairing or reaching devices is unavailable. + struct Notice: Equatable { + let text: String + let isWarning: Bool + } + + /// What the section says about pairing and reaching devices. One unavailable + /// state at a time, the one the person has to act on first. + static func sectionNotice(availability: DeviceServicesAvailability, account: DeviceAccountStatus, + deviceCount: Int, storageFailureMessage: String?) -> Notice { + switch availability { + case .secureEnclaveUnavailable: + return warning("This Mac has no Secure Enclave, so it can't keep device keys safe and can't pair.") + case .identityUnusable: + return warning("This Mac's device keys no longer work, for example after Migration Assistant or a restore. Reset the device identity, then pair your devices again.") + case .listRefused: + return warning("The list of paired devices failed its integrity check, so Ask Key won't use it. Reset the device identity, then pair your devices again.") + case .storageUnavailable: + return Notice(text: storageFailureMessage + ?? appLocalized("Devices are unavailable because Ask Key can't read this Mac's device keys right now."), + isWarning: true) + case .notConfigured, .loading, .ready: + break + } + if let message = accountMessage(account) { return Notice(text: message, isWarning: true) } + if deviceCount >= PairedDeviceList.maximumCount { + return warning("8 devices are paired, the most Ask Key allows. Remove one to pair another.") + } + return Notice(text: appLocalized("Pair Ask Key on your iPhone with this Mac. They exchange encrypted messages through iCloud, so both must use the same Apple Account."), isWarning: false) + } + + /// Why devices can't be reached, or nil when the account is available or + /// not checked yet (a Mac without a device identity makes no iCloud request). + static func accountMessage(_ account: DeviceAccountStatus) -> String? { + switch account { + case .unknown, .available: + return nil + case .noAccount: + return appLocalized("iCloud is signed out, so paired devices can't be reached. To pair, sign in with the same Apple Account as your iPhone.") + case .restricted: + return appLocalized("iCloud is restricted on this Mac, for example by Screen Time or a device management profile, so devices can't be reached.") + case .temporarilyUnavailable: + return appLocalized("Ask Key can't reach iCloud right now, so devices can't be reached. Check the connection.") + case .accountChanged: + return appLocalized("This Mac now uses a different Apple Account than the one these devices were paired in, so they can't be reached. Sign back in, or remove them and pair again.") + } + } + + /// Whether **Pair a Device…** can start a session now. A refused state + /// keeps its explanation on screen instead. + static func canPair(availability: DeviceServicesAvailability, account: DeviceAccountStatus, + deviceCount: Int, isPairing: Bool) -> Bool { + guard !isPairing, deviceCount < PairedDeviceList.maximumCount, account != .accountChanged else { return false } + switch availability { + case .ready, .loading, .storageUnavailable: return true + case .notConfigured, .secureEnclaveUnavailable, .identityUnusable, .listRefused: return false + } + } + + /// Whether the section offers **Reset Device Identity…**. + static func offersReset(_ availability: DeviceServicesAvailability) -> Bool { + availability == .identityUnusable || availability == .listRefused + } + + // MARK: Rows + + /// The platform the device declared about itself, never shown as verified. + static func platformName(_ platform: DevicePlatform) -> String { + switch platform { + case .iOS: return "iPhone" + case .macOS: return "Mac" + } + } + + static func platformSymbol(_ platform: DevicePlatform) -> String { + switch platform { + case .iOS: return "iphone" + case .macOS: return "laptopcomputer" + } + } + + /// "iPhone · Paired ". + static func rowSummary(_ row: PairedDeviceRow) -> String { + appLocalizedFormat("%1$@ · Paired %2$@", platformName(row.platform), date(row.pairedAt, time: false)) + } + + /// Whether the device was reached since pairing. + static func rowReachability(_ row: PairedDeviceRow) -> Notice { + switch row.reachability { + case .notReachedYet: + return Notice(text: appLocalized("Not reached yet"), isWarning: false) + case .reached(let date): + return Notice(text: appLocalizedFormat("Last reached %@", self.date(date, time: true)), isWarning: false) + case .unreachable: + return Notice(text: appLocalized("Can't be reached"), isWarning: true) + } + } + + /// The last delivery check, or nil before the first one and while the + /// device can't be reached (why is said above the list; an older result + /// would only contradict it). + static func rowDeliveryCheck(_ row: PairedDeviceRow) -> Notice? { + guard row.reachability != .unreachable else { return nil } + switch row.lastDeliveryCheck { + case .pending: return Notice(text: appLocalized("Delivery check sent"), isWarning: false) + case .replied: return Notice(text: appLocalized("Delivery check answered"), isWarning: false) + case .noReply: return Notice(text: appLocalized("Delivery check got no reply"), isWarning: true) + case nil: return nil + } + } + + static func issueMessage(_ issue: DeviceActionIssue) -> String { + switch issue { + case .managementRequired: + return appLocalized("Credential management is locked. Unlock it, then try again.") + case .deviceNotFound: + return appLocalized("This device is no longer paired.") + case .deliveryCheckFailed: + return appLocalized("The delivery check couldn't be sent. Check the connection, then try again.") + case .unpairNoticeNotSent: + return appLocalized("The device was removed here, but Ask Key couldn't tell it. Remove this Mac on that device too.") + case .actionFailed: + return appLocalized("That didn't work. Try again.") + } + } + + static func removalWarning(_ name: String) -> String { + appLocalizedFormat("This Mac stops trusting “%@” at once. Ask Key tries to tell the device so it can remove this Mac. If it still lists this Mac, remove this Mac on that device too.", name) + } + + static func date(_ date: Date, time: Bool) -> String { + let formatter = DateFormatter() + formatter.locale = AppLanguage.locale(for: AppLanguage.current) + formatter.dateStyle = .medium + formatter.timeStyle = time ? .short : .none + return formatter.string(from: date) + } + + private static func warning(_ key: String) -> Notice { + Notice(text: appLocalized(key), isWarning: true) + } +} diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift new file mode 100644 index 0000000..85ef914 --- /dev/null +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift @@ -0,0 +1,265 @@ +import AppKit +import AskKeyDevices +import CoreImage.CIFilterBuiltins + +/// What Settings → Devices keeps between redraws: the code the person is +/// typing, an inline rename and the open confirmations. Pairing, the list +/// and every rule about them belong to the coordinator; this model only +/// forwards the person's actions. It never holds or derives the Mac's +/// pairing code, and logs nothing. +@Observable +@MainActor +final class DevicesSettingsModel { + let coordinator: DeviceCoordinator + /// What the person typed into the code field. It is cleared once the + /// code is accepted, so the field never shows a matching code. + var codeEntry = "" + var renameDraft = "" + private(set) var renaming: DeviceID? + private(set) var removalCandidate: DeviceID? + private(set) var confirmingReset = false + /// A code check or a confirmation is running; **Confirm** waits for it, + /// so a double click can't count one entry twice. + private(set) var isSubmitting = false + /// **Pair Again** is replacing the session; the sheet stays open meanwhile. + private(set) var isRestarting = false + + @ObservationIgnored private var qrCache: (text: String, image: NSImage)? + + init(coordinator: DeviceCoordinator) { + self.coordinator = coordinator + } + + /// Builds without CloudKit, and every Release build until stage 4, have + /// no Devices section. + var isSectionVisible: Bool { coordinator.availability != .notConfigured } + + var notice: DevicesPresentation.Notice { + DevicesPresentation.sectionNotice(availability: availability, account: coordinator.account, + deviceCount: coordinator.devices.count, + storageFailureMessage: coordinator.storageFailureMessage) + } + + var canPair: Bool { + DevicesPresentation.canPair(availability: availability, account: coordinator.account, + deviceCount: coordinator.devices.count, isPairing: coordinator.pairing != nil) + } + + /// The coordinator's availability, except that a Mac without a Secure + /// Enclave says so before the first attempt, not only after it. + private var availability: DeviceServicesAvailability { + let missingEnclave = coordinator.environment.backend?.isSecureEnclaveAvailable == false + if coordinator.availability == .ready, !coordinator.hasIdentity, missingEnclave { return .secureEnclaveUnavailable } + return coordinator.availability + } + + var offersReset: Bool { DevicesPresentation.offersReset(coordinator.availability) } + + // MARK: Pairing sheet + + var isSheetPresented: Bool { coordinator.pairing != nil || isRestarting } + + var sheet: PairingSheetPresentation? { + coordinator.pairing.map { + PairingSheetPresentation(state: $0, feedback: coordinator.pairingFeedback, + isAuthenticating: coordinator.isAuthenticatingPairing) + } + } + + /// Whether **Confirm** does something now: check a typed entry, or ask + /// for system authentication again for an accepted code. + var canConfirm: Bool { + guard !isSubmitting else { return false } + switch coordinator.pairing { + case .enteringCode: return !codeEntry.isEmpty + case .codeAccepted: return !coordinator.isAuthenticatingPairing + default: return false + } + } + + /// The section appeared. The model outlives each visit to Settings, so + /// a rename or confirmation left open on an earlier visit is closed. + func sectionOpened() async { + cancelRename() + removalCandidate = nil + confirmingReset = false + await coordinator.devicesOpened() + } + + func pairDevice() async { + // T6 TEMP #298, revert before merge. + DevicesPairingTrace.log("model.pairDevice: canPair=\(canPair) availability=\(coordinator.availability) " + + "shown availability=\(availability) account=\(coordinator.account) deviceCount=\(coordinator.devices.count) " + + "isPairing=\(coordinator.pairing != nil) identity=\(coordinator.hasIdentity) " + + "secureEnclave=\(coordinator.environment.backend?.isSecureEnclaveAvailable.description ?? "no backend")") + guard canPair else { + DevicesPairingTrace.log("model.pairDevice: returned at guard canPair") + return + } + codeEntry = "" + await coordinator.startPairing() + DevicesPairingTrace.log("model.pairDevice: startPairing returned, pairing=\(DevicesPairingTrace.describe(coordinator.pairing)) " + + "isSheetPresented=\(isSheetPresented)") + } + + /// **Confirm**: checks the typed code; a wrong or malformed entry asks + /// for nothing. An accepted code asks for system authentication at once; + /// after a cancelled or failed prompt, **Confirm** asks again without a + /// new entry. + func confirm() async { + guard canConfirm else { return } + isSubmitting = true + defer { isSubmitting = false } + if case .enteringCode = coordinator.pairing { + await coordinator.enterPairingCode(codeEntry) + switch coordinator.pairing { + case .enteringCode: + if case .wrongCode = coordinator.pairingFeedback { codeEntry = "" } + return + case .codeAccepted: + codeEntry = "" + default: + codeEntry = "" + return + } + } + guard case .codeAccepted = coordinator.pairing else { return } + await coordinator.confirmPairing() + } + + /// **Cancel**, **Done**, **Close** or closing the sheet. A session that has + /// not added the device ends with nothing stored; an added one stays. + func closeSheet() async { + codeEntry = "" + qrCache = nil + await coordinator.cancelPairing() + } + + /// **Pair Again** after a session ended or its QR code expired: a new + /// session and a new QR code in the same sheet. + func pairAgain() async { + guard !isRestarting else { return } + isRestarting = true + defer { isRestarting = false } + codeEntry = "" + qrCache = nil + if coordinator.pairing?.isActive == true { await coordinator.cancelPairing() } + guard DevicesPresentation.canPair(availability: availability, account: coordinator.account, + deviceCount: coordinator.devices.count, isPairing: false) else { + await coordinator.cancelPairing() + return + } + await coordinator.startPairing() + } + + /// The QR code image, drawn once per session with Core Image. + func qrImage(for text: String) -> NSImage? { + if let qrCache, qrCache.text == text { return qrCache.image } + guard let image = Self.makeQRImage(text) else { return nil } + qrCache = (text, image) + return image + } + + static func makeQRImage(_ text: String) -> NSImage? { + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(text.utf8) + filter.correctionLevel = "M" + guard let output = filter.outputImage else { return nil } + let scaled = output.transformed(by: CGAffineTransform(scaleX: 8, y: 8)) + // The software renderer needs no GPU and the image is a few hundred points. + let context = CIContext(options: [.useSoftwareRenderer: true, .cacheIntermediates: false]) + guard let image = context.createCGImage(scaled, from: scaled.extent) else { return nil } + return NSImage(cgImage: image, size: scaled.extent.size) + } + + // MARK: Rows + + var canSaveRename: Bool { !DeviceDisplayName.clean(renameDraft).isEmpty } + + func beginRename(_ row: PairedDeviceRow) { + removalCandidate = nil + renaming = row.id + renameDraft = row.displayName + } + + func cancelRename() { + renaming = nil + renameDraft = "" + } + + func saveRename() async { + guard canSaveRename, let id = renaming, let row = coordinator.devices.first(where: { $0.id == id }) else { return } + let renamed = await coordinator.renameDevice(row, to: renameDraft) + if renamed || coordinator.lastIssue == .deviceNotFound { cancelRename() } + } + + /// A delivery check needs the device services and the account the + /// devices were paired in. + var canTestDelivery: Bool { + coordinator.availability == .ready && coordinator.account == .available + } + + func testDelivery(_ row: PairedDeviceRow) async { + guard canTestDelivery else { return } + await coordinator.sendDeliveryCheck(to: row.id) + } + + /// **Remove** asks first; only the confirmation removes the device. + func requestRemoval(_ row: PairedDeviceRow) { + cancelRename() + removalCandidate = row.id + } + + func cancelRemoval() { + removalCandidate = nil + } + + func confirmRemoval() async { + guard let id = removalCandidate else { return } + removalCandidate = nil + await coordinator.removeDevice(id) + // After an account change, the new account becomes the list's once + // the list is empty, which only an account check notices. + if coordinator.account == .accountChanged, coordinator.devices.isEmpty { + await coordinator.devicesOpened() + } + } + + // MARK: Reset + + /// **Reset Device Identity…** asks first; only the confirmation deletes + /// the keys and the list. + func requestReset() { + guard offersReset else { return } + confirmingReset = true + } + + func cancelReset() { + confirmingReset = false + } + + func confirmReset() async { + guard confirmingReset else { return } + confirmingReset = false + await coordinator.resetIdentity() + } +} + +// T6 TEMP #298 diagnostics, revert before merge: what Pair a Device… does on +// a click, read by the E2E fixture. Pairing states by case name, failures in +// full; neither carries a code or QR payload. +@MainActor +package enum DevicesPairingTrace { + package private(set) static var lines: [String] = [] + + static func log(_ text: String) { + lines.append(String(format: "%.3f ", Date().timeIntervalSince1970) + text) + } + + static func describe(_ state: PairingSheetState?) -> String { + guard let state else { return "nil" } + if case .failed = state { return String(describing: state) } + if case .ended = state { return String(describing: state) } + return String(String(describing: state).prefix { $0 != "(" }) + } +} diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift new file mode 100644 index 0000000..8e2d869 --- /dev/null +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift @@ -0,0 +1,252 @@ +import AskKeyDevices +import SwiftUI + +/// Settings → Devices (ADR 0013 → Mac App): **Pair a Device…**, the paired +/// devices with **Rename**, **Test Delivery** and **Remove**, and why pairing +/// or reaching them is unavailable. Builds without CloudKit show nothing. +struct DevicesSettingsSection: View { + /// Held by `VaultViewModel` for the coordinator's life and passed in, as + /// the Backup section receives the vault; this view owns no model. + let model: DevicesSettingsModel? + + @MainActor + init() { + self.init(model: VaultViewModel.devicesSettingsModel) + } + + init(model: DevicesSettingsModel?) { + self.model = model + } + + var body: some View { + // A separate view keeps its body across the parent's redraw, so it + // subscribes to the language itself. + let _ = AppLanguage.store.resolved + if let model, model.isSectionVisible { + DevicesSectionContent(model: model) + } + } +} + +private struct DevicesSectionContent: View { + @Bindable var model: DevicesSettingsModel + + var body: some View { + // Each separate view subscribes to the language itself; the parent's + // redraw does not re-run a body whose inputs are unchanged. + let _ = AppLanguage.store.resolved + let notice = model.notice + GroupedList(header: appLocalized("Devices")) { + DevicesSettingRow(title: appLocalized("Pairing"), message: notice.text, isWarning: notice.isWarning) { + if model.offersReset { + Button(appLocalized("Reset Device Identity…"), role: .destructive) { model.requestReset() } + .buttonStyle(.irreversibleAction) + .dimmedAndDisabled(model.confirmingReset) + .accessibilityIdentifier("settings-reset-device-identity") + } else { + Button(appLocalized("Pair a Device…"), action: pairDevice) + .buttonStyle(.secondaryAction) + .dimmedAndDisabled(!model.canPair) + .accessibilityIdentifier("settings-pair-device") + } + } + // The session decides whether the sheet shows. A live session ends + // only through the sheet's buttons or the end of the management + // session, so a dismissal reported by SwiftUI alone never cancels + // a pairing; it only clears a session that has already finished. + .sheet(isPresented: Binding( + get: { model.isSheetPresented }, + set: { presented in + if !presented, model.coordinator.pairing?.isActive != true { Task { await model.closeSheet() } } + } + )) { + DevicePairingSheet(model: model) + } + if model.confirmingReset { + GroupedListSeparator() + DevicesConfirmationRow(warning: appLocalized("Delete this Mac's device keys and its list of paired devices? Then remove this Mac on each paired device and pair again. This can't be undone.")) { + Button(appLocalized("Cancel")) { model.cancelReset() } + .buttonStyle(.secondaryAction) + Button(appLocalized("Reset"), role: .destructive) { Task { await model.confirmReset() } } + .buttonStyle(FrozenDangerButtonStyle()) + .accessibilityIdentifier("settings-confirm-reset-device-identity") + } + } + ForEach(model.coordinator.devices) { row in + GroupedListSeparator() + DeviceRow(model: model, row: row) + } + if let issue = model.coordinator.lastIssue { + GroupedListSeparator() + Text(DevicesPresentation.issueMessage(issue)) + .font(Theme.Fonts.secondary) + .foregroundStyle(Theme.warning) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Theme.Spacing.lg) + .padding(.vertical, Theme.Spacing.md) + .accessibilityIdentifier("settings-devices-issue") + } + } + .onAppear { Task { await model.sectionOpened() } } + } + + private func pairDevice() { + DevicesPairingTrace.log("button action: Pair a Device…") // T6 TEMP #298, revert before merge + Task { await model.pairDevice() } + } +} + +/// One paired device: name, platform, paired date, last reached and the +/// last delivery check, with its actions or an open rename or removal. +private struct DeviceRow: View { + @Bindable var model: DevicesSettingsModel + let row: PairedDeviceRow + @FocusState private var nameFocused: Bool + + var body: some View { + let _ = AppLanguage.store.resolved + let isRenaming = model.renaming == row.id + let reachability = DevicesPresentation.rowReachability(row) + let check = DevicesPresentation.rowDeliveryCheck(row) + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: Theme.Spacing.md) { + Image(systemName: DevicesPresentation.platformSymbol(row.platform)) + .font(Theme.Icon.inlineStatus) + .foregroundStyle(Theme.textSecondary) + .frame(width: 24) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + if isRenaming { + TextField(appLocalized("Name"), text: $model.renameDraft) + .textFieldStyle(.roundedBorder) + .font(Theme.Fonts.body) + .frame(maxWidth: 240) + .focused($nameFocused) + .onSubmit { Task { await model.saveRename() } } + .onAppear { nameFocused = true } + .accessibilityIdentifier("devices-rename-field") + } else { + Text(row.displayName) + .font(Theme.Fonts.body.weight(.medium)) + .foregroundStyle(Theme.text) + .lineLimit(1) + .truncationMode(.middle) + } + Text(DevicesPresentation.rowSummary(row)) + .font(Theme.Fonts.secondary) + .foregroundStyle(Theme.textSecondary) + statusLine(reachability) + .accessibilityIdentifier("devices-row-reachability") + if let check { + statusLine(check) + .accessibilityIdentifier("devices-row-delivery-check") + } + } + Spacer(minLength: Theme.Spacing.md) + if isRenaming { + Button(appLocalized("Cancel")) { model.cancelRename() } + .buttonStyle(.secondaryAction) + .keyboardShortcut(.cancelAction) + Button(appLocalized("Save")) { Task { await model.saveRename() } } + .buttonStyle(.secondaryAction) + .dimmedAndDisabled(!model.canSaveRename) + .accessibilityIdentifier("devices-rename-save") + } else { + Button(appLocalized("Rename")) { model.beginRename(row) } + .buttonStyle(.secondaryAction) + .accessibilityIdentifier("devices-rename") + Button(appLocalized("Test Delivery")) { Task { await model.testDelivery(row) } } + .buttonStyle(.secondaryAction) + .dimmedAndDisabled(!model.canTestDelivery) + .accessibilityIdentifier("devices-test-delivery") + Button(appLocalized("Remove"), role: .destructive) { model.requestRemoval(row) } + .buttonStyle(.irreversibleAction) + .dimmedAndDisabled(model.removalCandidate == row.id) + .accessibilityIdentifier("devices-remove") + } + } + .padding(.horizontal, Theme.Spacing.lg) + .padding(.vertical, Theme.Spacing.md) + if model.removalCandidate == row.id { + GroupedListSeparator() + DevicesConfirmationRow(warning: DevicesPresentation.removalWarning(row.displayName)) { + Button(appLocalized("Cancel")) { model.cancelRemoval() } + .buttonStyle(.secondaryAction) + Button(appLocalized("Remove"), role: .destructive) { Task { await model.confirmRemoval() } } + .buttonStyle(FrozenDangerButtonStyle()) + .accessibilityIdentifier("devices-confirm-remove") + } + } + } + } + + private func statusLine(_ line: DevicesPresentation.Notice) -> some View { + Text(line.text) + .font(Theme.Fonts.secondary) + .foregroundStyle(line.isWarning ? Theme.warning : Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } +} + +/// A Settings row whose message turns to the warning role when it explains +/// why something is unavailable. +private struct DevicesSettingRow: View { + let title: String + let message: String + let isWarning: Bool + @ViewBuilder let control: Control + + var body: some View { + HStack(alignment: .center, spacing: Theme.Spacing.lg) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(Theme.Fonts.body) + .foregroundStyle(Theme.text) + Text(message) + .font(Theme.Fonts.secondary) + .foregroundStyle(isWarning ? Theme.warning : Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("settings-devices-notice") + } + Spacer(minLength: Theme.Spacing.md) + control + } + .padding(.horizontal, Theme.Spacing.lg) + .padding(.vertical, Theme.Spacing.md) + .frame(minHeight: 44) + } +} + +/// The inline confirmation used across Settings: a warning glyph, the +/// consequence, and the actions on the subtle warning surface. +private struct DevicesConfirmationRow: View { + let warning: String + @ViewBuilder let actions: Actions + + var body: some View { + HStack(alignment: .center, spacing: Theme.Spacing.md) { + Image(systemName: "exclamationmark.triangle.fill") + .font(Theme.Fonts.secondary) + .foregroundStyle(Theme.warning) + .accessibilityHidden(true) + Text(warning) + .font(Theme.Fonts.secondary) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: Theme.Spacing.md) + actions + } + .padding(.horizontal, Theme.Spacing.lg) + .padding(.vertical, Theme.Spacing.md) + .background(Theme.warningSubtle) + } +} + +private extension View { + /// The shared secondary button styles draw the same when disabled, so an + /// unavailable Devices action is also dimmed. + func dimmedAndDisabled(_ disabled: Bool) -> some View { + self.disabled(disabled).opacity(disabled ? 0.45 : 1) + } +} diff --git a/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift b/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift index 79bcb8b..42034fa 100644 --- a/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift +++ b/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift @@ -35,10 +35,12 @@ struct FrozenSettingsPage: View { agentAccessSection generalSection BackupSettingsSection() + DevicesSettingsSection() dataSection } .padding(Theme.Spacing.xxl) } + .accessibilityIdentifier("settings-page") .scrollPosition(id: $settingsScrollTarget, anchor: .center) .background(Theme.windowBackground) .onAppear { diff --git a/Sources/AskKeyAppKit/Views/Settings/PairingSheetPresentation.swift b/Sources/AskKeyAppKit/Views/Settings/PairingSheetPresentation.swift new file mode 100644 index 0000000..d9f1f64 --- /dev/null +++ b/Sources/AskKeyAppKit/Views/Settings/PairingSheetPresentation.swift @@ -0,0 +1,181 @@ +import AskKeyDevices +import Foundation + +/// What the pairing sheet shows for one coordinator state (ADR 0013 → +/// Pairing). The state carries no code, so neither does anything here: +/// the sheet never shows the Mac's code. Before the person confirms on the +/// Mac, nothing says whether the iPhone has confirmed. +struct PairingSheetPresentation: Equatable { + enum Stage: Equatable { + case preparing + case qrCode(text: String, expiresAt: Date) + case waitingForIPhone + /// The six-digit field. `accepted` once the code matched: the field + /// takes no further entry and **Confirm** asks for system authentication. + case codeEntry(accepted: Bool) + case waitingForIPhoneConfirmation + case paired + case ended(canPairAgain: Bool) + case failed + } + + let stage: Stage + let title: String + let message: String? + /// The device that scanned the QR code, as it describes itself. + let peer: PairingPeer? + /// A warning shown on the subtle warning surface. + let notice: String? + /// The result of the person's last action, shown under the field. + let feedback: String? + /// A wrong or malformed entry, or a cancelled or failed authentication. + let feedbackIsWarning: Bool + + init(state: PairingSheetState, feedback: PairingFeedback?, isAuthenticating: Bool) { + var peer: PairingPeer? + var notice: String? + var feedbackText: String? + var feedbackIsWarning = true + let stage: Stage + let title: String + var message: String? + switch state { + case .preparing: + stage = .preparing + title = appLocalized("Scan with Your iPhone") + message = appLocalized("Preparing…") + case .showingQRCode(let text, let expiresAt): + stage = .qrCode(text: text, expiresAt: expiresAt) + title = appLocalized("Scan with Your iPhone") + message = appLocalized("In Ask Key on your iPhone, tap Pair a Mac, then scan this QR code.") + notice = appLocalized("Scan only the QR code in this window. Never scan a QR code that someone sends you.") + case .waitingForIPhone(let waitingPeer, _): + stage = .waitingForIPhone + peer = waitingPeer + title = appLocalized("Waiting for the iPhone") + message = appLocalized("The iPhone shows a pairing code once it has answered, and this window then asks for it.") + notice = appLocalized("If the iPhone has shown a code for more than about 10 seconds while this window still waits, cancel and pair again.") + case .enteringCode(let enteringPeer, _): + stage = .codeEntry(accepted: false) + peer = enteringPeer + title = appLocalized("Enter the Pairing Code") + message = Self.codeInstruction + feedbackText = Self.entryFeedback(feedback) + case .codeAccepted(let acceptedPeer): + stage = .codeEntry(accepted: true) + peer = acceptedPeer + title = appLocalized("Confirm Pairing") + message = appLocalized("The code matches. Confirm to finish pairing; Ask Key asks for system authentication.") + if isAuthenticating { + feedbackText = appLocalized("Waiting for system authentication…") + feedbackIsWarning = false + } else { + feedbackText = Self.authenticationFeedback(feedback) + } + case .waitingForIPhoneConfirmation(let confirmingPeer): + stage = .waitingForIPhoneConfirmation + peer = confirmingPeer + title = appLocalized("Confirm on the iPhone") + message = appLocalized("Confirmed on this Mac. Tap Confirm Pairing on the iPhone to finish.") + case .paired(let pairedPeer): + stage = .paired + title = appLocalized("Paired") + message = appLocalizedFormat("This Mac and “%@” are paired. Ask Key sent a delivery check; until the device answers, it shows as “Not reached yet”.", pairedPeer.displayName) + case .ended(let reason): + stage = .ended(canPairAgain: reason != .identityUnusable && reason != .deviceLimitReached) + title = appLocalized("Pairing Ended") + message = Self.endMessage(reason) + case .failed(let failure): + stage = .failed + title = appLocalized("Pairing Couldn't Start") + message = Self.startFailureMessage(failure) + } + self.stage = stage + self.title = title + self.message = message + self.peer = peer + self.notice = notice + self.feedback = feedbackText + self.feedbackIsWarning = feedbackIsWarning + } + + static var codeInstruction: String { + appLocalized("Enter the code shown on the iPhone's Ask Key pairing screen. Never enter a code from a notification or a message. If that screen shows no code, cancel.") + } + + /// "iPhone (not verified)": the platform is what the device says about itself. + static func peerPlatform(_ peer: PairingPeer) -> String { + appLocalizedFormat("%@ (not verified)", DevicesPresentation.platformName(peer.platform)) + } + + static func entryFeedback(_ feedback: PairingFeedback?) -> String? { + switch feedback { + case .invalidCodeFormat: + return appLocalized("Enter the six half-width digits shown on the iPhone.") + case .wrongCode(let remaining): + return AppLanguage.localizedCount("That code doesn't match. %lld attempts left.", + oneKey: "That code doesn't match. %lld attempt left.", + count: remaining, language: AppLanguage.current) + case .authenticationCancelled, .authenticationFailed, nil: + return nil + } + } + + static func authenticationFeedback(_ feedback: PairingFeedback?) -> String? { + switch feedback { + case .authenticationCancelled: + return appLocalized("System authentication was cancelled. Nothing was sent; you can confirm again.") + case .authenticationFailed: + return appLocalized("System authentication didn't succeed. Nothing was sent; you can confirm again.") + case .invalidCodeFormat, .wrongCode, nil: + return nil + } + } + + static func endMessage(_ reason: PairingEndReason) -> String { + switch reason { + case .cancelled: + return appLocalized("Pairing was cancelled. Nothing was stored.") + case .codeMismatch: + return appLocalized("The code didn't match three times, so this pairing ended. Nothing was stored. Pair again to get a new QR code.") + case .commitmentMismatch, .conflictingChallenge: + return appLocalized("The iPhone's answer didn't match its request, so this pairing ended. Nothing was stored. Pair again, and scan only the QR code in this window.") + case .cancelledByPeer, .noChallenge: + return appLocalized("Pairing was cancelled on the iPhone. Nothing was stored.") + case .undoneByPeer: + return appLocalized("The iPhone cancelled this pairing after it finished, so the device was removed again.") + case .secondRequest: + return appLocalized("A second device answered this QR code, so this pairing ended. Nothing was stored. Pair again, and make sure nobody else can see the QR code.") + case .expired: + return appLocalized("The pairing session expired. Nothing was stored. Pair again to get a new QR code.") + case .deviceLimitReached: + return appLocalized("8 devices are already paired, the most Ask Key allows. Nothing was stored. Remove one, then pair again.") + case .keyConflict: + return appLocalized("This device's keys conflict with a device that is already paired. Nothing was stored.") + case .peerRemoved: + return appLocalized("The device was removed while pairing, so this pairing ended. Nothing was stored.") + case .identityUnusable: + return appLocalized("This Mac's device keys no longer work. Nothing was stored. Reset the device identity in Settings, then pair again.") + } + } + + static func startFailureMessage(_ failure: PairingStartFailure) -> String { + switch failure { + case .managementRequired: + return appLocalized("Unlock credential management to pair a device.") + case .secureEnclaveUnavailable: + return appLocalized("This Mac has no Secure Enclave, so it can't keep device keys safe and can't pair.") + case .accountUnavailable(let status): + return DevicesPresentation.accountMessage(status) + ?? appLocalized("Ask Key can't reach iCloud right now, so devices can't be reached. Check the connection.") + case .iCloudUnavailable: + return appLocalized("Ask Key couldn't prepare its iCloud inbox. Check the connection, then try again.") + case .identityUnusable: + return appLocalized("This Mac's device keys no longer work, for example after Migration Assistant or a restore. Reset the device identity, then pair your devices again.") + case .storageUnavailable: + return appLocalized("Ask Key couldn't read or save this Mac's device keys. Try again.") + case .keyCreationFailed: + return appLocalized("The Secure Enclave didn't create the keys. Try again.") + } + } +} diff --git a/Tests/AskKeyAppTests/AppLanguageCatalogTests.swift b/Tests/AskKeyAppTests/AppLanguageCatalogTests.swift index 196e253..7134d9c 100644 --- a/Tests/AskKeyAppTests/AppLanguageCatalogTests.swift +++ b/Tests/AskKeyAppTests/AppLanguageCatalogTests.swift @@ -87,6 +87,48 @@ final class AppLanguageCatalogTests: AppLanguageExperienceTestSupport { } } + /// Devices wording (#288): "配对码" is only the six digits the iPhone + /// shows, the scanned image is always "二维码", and the iPhone app's terms + /// are reused. + func testDevicesCopyUsesTheGlossaryTermsInChinese() { + let chinese = AppLanguage.table(language: "zh-Hans") + let english = AppLanguage.table(language: "en") + XCTAssertEqual(chinese["Not reached yet"], "还没联系上") + XCTAssertEqual(chinese["Devices"], "设备") + XCTAssertEqual(chinese["Pair a Device…"], "配对设备…") + XCTAssertEqual(chinese["Pairing code"], "配对码") + XCTAssertEqual(chinese["Test Delivery"], "测试送达") + XCTAssertEqual(chinese["push.message.title"], "请旨") + XCTAssertEqual(english["push.message.title"], "Ask Key") + XCTAssertEqual(chinese["push.message.body"], "一台已配对的设备发来一条消息。") + XCTAssertEqual(english["push.message.body"], "A paired device sent a message.") + let qrEntries = english.filter { $0.value.contains("QR code") } + XCTAssertGreaterThanOrEqual(qrEntries.count, 6) + for (key, value) in qrEntries { + let translation = chinese[key] ?? "" + XCTAssertTrue(translation.contains("二维码"), "\(value) → \(translation)") + if !value.lowercased().contains("code didn't match") { + XCTAssertFalse(translation.contains("配对码"), "the QR code is never a pairing code: \(translation)") + } + } + } + + func testDeviceKeychainConflictsAreTranslated() { + let previous = AppLanguage.current + defer { AppLanguage.current = previous } + for kind in [AppKeychainItemKind.deviceMessageKey, .deviceAgreementKey, .deviceApprovalKey, .pairedDevices] { + let error = AppKeyStoreError.conflictingItem(kind) + AppLanguage.current = "en" + XCTAssertEqual(UserFacingCopy.message(for: error), kind.conflictMessage) + AppLanguage.current = "zh-Hans" + let chinese = UserFacingCopy.message(for: error) + XCTAssertNotEqual(chinese, kind.conflictMessage) + XCTAssertTrue(chinese.contains("钥匙串访问")) + XCTAssertTrue(chinese.contains("login.keychain-db")) + XCTAssertTrue(chinese.contains("请勿移除 vault-key 项目")) + } + } + func testBackupPromptsAndRenameSuffixFollowTheSelectedLanguage() { let prompts: [(ManagementAuthenticationAction, String)] = [ (.exportBackup, "确认将全部凭证导出为备份文件"), diff --git a/Tests/AskKeyAppTests/DeviceCoordinatorPairingTests.swift b/Tests/AskKeyAppTests/DeviceCoordinatorPairingTests.swift index ae8efd5..54786ad 100644 --- a/Tests/AskKeyAppTests/DeviceCoordinatorPairingTests.swift +++ b/Tests/AskKeyAppTests/DeviceCoordinatorPairingTests.swift @@ -53,7 +53,7 @@ final class DeviceCoordinatorPairingTests: AskKeyAppTestCase { await coordinator.confirmPairing() XCTAssertEqual(coordinator.pairing, .waitingForIPhoneConfirmation(peer: peer)) - XCTAssertEqual(harness.script.authenticationReasons, [Vault.devicePairingReason]) + XCTAssertEqual(harness.script.authenticationReasons, [ManagementAuthenticationAction.pairDevice.reasonKey]) XCTAssertEqual(harness.macConfirmations().count, 1) XCTAssertTrue(coordinator.devices.isEmpty, "nothing is added before the iPhone's confirmation") diff --git a/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift b/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift new file mode 100644 index 0000000..5768e05 --- /dev/null +++ b/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift @@ -0,0 +1,406 @@ +import AskKeyDevicesTestSupport +import AskKeyTestSupport +import Foundation +import XCTest +@testable import AskKeyAppKit +@testable import AskKeyDevices +@testable import AskKeyVault + +/// Settings → Devices as the person drives it (#288): the model over the +/// real coordinator, with software keys, the in-memory transport and a +/// simulated iPhone. +@MainActor +final class DevicesSettingsModelTests: AskKeyAppTestCase { + private var harness: DeviceCoordinatorHarness! + private var model: DevicesSettingsModel! + private let previousLanguage = AppLanguage.current + + override func setUp() async throws { + AppLanguage.current = "en" + harness = try DeviceCoordinatorHarness() + await harness.coordinator.launch() + model = DevicesSettingsModel(coordinator: harness.coordinator) + } + + override func tearDown() async throws { + model = nil + try harness.close() + harness = nil + AppLanguage.current = previousLanguage + XCTAssertFalse(SessionPolicy.isIdleExpiryHeld, "no test leaves idle expiry held") + } + + func testTheSectionIsAbsentWithoutCloudKitAndWithoutDeviceServices() throws { + let library = try AgentAccessPolicyTestLibrary() + defer { try? library.close() } + let coordinator = DeviceCoordinator(environment: DeviceCoordinatorEnvironment( + vault: library.vault, backend: nil, inboxState: VaultDeviceInboxStateStore(vault: library.vault), + authenticate: { _ in .authenticated(.allow) }, isManagementSessionActive: { true }, + automaticPolling: false + )) + XCTAssertFalse(DevicesSettingsModel(coordinator: coordinator).isSectionVisible) + // This process has no CloudKit entitlement, so whatever it installed is not configured. + XCTAssertFalse(VaultViewModel.devicesSettingsModel?.isSectionVisible ?? false) + XCTAssertTrue(model.isSectionVisible, "a configured build shows the section") + } + + func testPairingNeedsTheManagementSession() async throws { + harness.script.managementActive = false + await model.pairDevice() + let sheet = try XCTUnwrap(model.sheet) + XCTAssertEqual(sheet.stage, .failed) + XCTAssertEqual(sheet.message, "Unlock credential management to pair a device.") + XCTAssertTrue(harness.library.keys.deviceItems.isEmpty, "nothing is created without the management session") + await model.closeSheet() + XCTAssertFalse(model.isSheetPresented) + } + + func testTheCodeFieldAppearsOnlyAfterTheVerifiedReveal() async throws { + await model.pairDevice() + guard case .qrCode(let text, _) = try XCTUnwrap(model.sheet).stage else { return XCTFail("the QR code") } + XCTAssertNotNil(model.qrImage(for: text), "Core Image draws the QR code") + XCTAssertEqual(model.sheet?.notice, "Scan only the QR code in this window. Never scan a QR code that someone sends you.") + XCTAssertFalse(try XCTUnwrap(model.sheet).shownText.contains { $0.contains(text) }, "the payload is drawn, never written") + + try await harness.phone.scan(text) + await harness.coordinator.refresh() + let waiting = try XCTUnwrap(model.sheet) + XCTAssertEqual(waiting.stage, .waitingForIPhone, "no field before the reveal") + XCTAssertEqual(waiting.peer?.displayName, "Synthetic Phone") + XCTAssertTrue(waiting.shownText.contains("iPhone (not verified)"), "the platform is never shown as verified") + XCTAssertEqual(waiting.notice, "If the iPhone has shown a code for more than about 10 seconds while this window still waits, cancel and pair again.") + model.codeEntry = "123456" + XCTAssertFalse(model.canConfirm) + await model.confirm() + XCTAssertEqual(model.sheet?.stage, .waitingForIPhone, "an entry before the reveal is not taken") + + try await harness.phone.poll() + await harness.coordinator.refresh() + XCTAssertEqual(model.sheet?.stage, .codeEntry(accepted: false)) + XCTAssertEqual(model.sheet?.message, PairingSheetPresentation.codeInstruction) + await model.closeSheet() + } + + func testAWrongCodeAsksForNoAuthenticationAndCountsOneAttempt() async throws { + let code = try await reachCodeEntry() + model.codeEntry = "12 34" + await model.confirm() + XCTAssertEqual(model.sheet?.feedback, "Enter the six half-width digits shown on the iPhone.") + XCTAssertEqual(model.codeEntry, "12 34", "a malformed entry stays for correcting and is not counted") + guard case .enteringCode(_, 3) = harness.coordinator.pairing else { return XCTFail("still three attempts") } + + model.codeEntry = Self.wrong(code) + await model.confirm() + XCTAssertEqual(model.sheet?.feedback, "That code doesn't match. 2 attempts left.") + XCTAssertEqual(model.sheet?.feedbackIsWarning, true) + XCTAssertEqual(model.codeEntry, "") + guard case .enteringCode(_, 2) = harness.coordinator.pairing else { return XCTFail("one attempt counted") } + model.codeEntry = Self.wrong(code) + await model.confirm() + XCTAssertEqual(model.sheet?.feedback, "That code doesn't match. 1 attempt left.") + XCTAssertTrue(harness.script.authenticationReasons.isEmpty, "a wrong code asks for no authentication") + XCTAssertTrue(harness.macConfirmations().isEmpty) + await model.closeSheet() + } + + func testAMatchingCodeNeedsAuthenticationACancelStoresNothingAndARetryNeedsNoNewEntry() async throws { + let code = try await reachCodeEntry() + harness.script.authenticationResults = [.cancelled, .failed] + model.codeEntry = code + + await model.confirm() + XCTAssertEqual(harness.script.authenticationReasons, [ManagementAuthenticationAction.pairDevice.reasonKey], + "the matching code asks for system authentication with the pairing reason at once") + XCTAssertEqual(model.sheet?.stage, .codeEntry(accepted: true)) + XCTAssertEqual(model.sheet?.title, "Confirm Pairing") + XCTAssertEqual(model.sheet?.message, "The code matches. Confirm to finish pairing; Ask Key asks for system authentication.") + XCTAssertEqual(model.sheet?.feedback, "System authentication was cancelled. Nothing was sent; you can confirm again.") + XCTAssertEqual(model.codeEntry, "", "the accepted entry is not shown again") + XCTAssertTrue(model.canConfirm, "confirming again needs no new entry") + XCTAssertTrue(harness.macConfirmations().isEmpty) + XCTAssertTrue(harness.coordinator.devices.isEmpty) + XCTAssertNil(harness.library.keys.deviceItems[.pairedDevices], "a cancelled authentication stores nothing") + + await model.confirm() + XCTAssertEqual(model.sheet?.feedback, "System authentication didn't succeed. Nothing was sent; you can confirm again.") + XCTAssertTrue(harness.macConfirmations().isEmpty) + + await model.confirm() + XCTAssertEqual(harness.script.authenticationReasons.count, 3) + XCTAssertEqual(model.sheet?.stage, .waitingForIPhoneConfirmation) + XCTAssertEqual(harness.macConfirmations().count, 1) + try await harness.phone.confirm() + try await harness.phone.poll() + await harness.coordinator.refresh() + XCTAssertEqual(model.sheet?.stage, .paired) + XCTAssertEqual(harness.coordinator.devices.count, 1) + await model.closeSheet() + XCTAssertEqual(harness.coordinator.devices.count, 1, "Done keeps the device") + } + + func testThreeWrongCodesExpiryAndASecondResponderStoreNothing() async throws { + let code = try await reachCodeEntry() + for _ in 0..<3 { + model.codeEntry = Self.wrong(code) + await model.confirm() + } + XCTAssertEqual(model.sheet?.stage, .ended(canPairAgain: true)) + XCTAssertEqual(model.sheet?.message, "The code didn't match three times, so this pairing ended. Nothing was stored. Pair again to get a new QR code.") + + await model.pairAgain() + guard case .qrCode(let text, _) = try XCTUnwrap(model.sheet).stage else { return XCTFail("a new QR code") } + XCTAssertTrue(model.isSheetPresented) + harness.clock.advance(by: 11 * 60) + await harness.coordinator.refresh() + XCTAssertEqual(model.sheet?.message, "The pairing session expired. Nothing was stored. Pair again to get a new QR code.") + + await model.pairAgain() + guard case .qrCode(let second, _) = try XCTUnwrap(model.sheet).stage else { return XCTFail("a new QR code") } + XCTAssertNotEqual(second, text) + let other = SimulatedPairingPhone(transport: harness.transport, clock: harness.clock) + try await harness.phone.scan(second) + try await other.scan(second, name: "Synthetic Other Phone") + await harness.coordinator.refresh() + XCTAssertEqual(model.sheet?.message, "A second device answered this QR code, so this pairing ended. Nothing was stored. Pair again, and make sure nobody else can see the QR code.") + + XCTAssertTrue(harness.coordinator.devices.isEmpty) + XCTAssertNil(harness.library.keys.deviceItems[.pairedDevices]) + XCTAssertTrue(harness.script.authenticationReasons.isEmpty) + await model.closeSheet() + } + + func testTheSheetNeverShowsTheMacsCode() async throws { + var shown: [String] = [] + func record() { shown += model.sheet?.shownText ?? []; shown.append(model.codeEntry) } + await model.pairDevice() + record() + guard case .qrCode(let text, _) = try XCTUnwrap(model.sheet).stage else { return XCTFail("the QR code") } + try await harness.phone.scan(text) + await harness.coordinator.refresh() + record() + try await harness.phone.poll() + await harness.coordinator.refresh() + record() + let shownCode = await harness.phone.shownCode() + let code = try XCTUnwrap(shownCode) + model.codeEntry = Self.wrong(code) + await model.confirm() + record() + harness.script.authenticationResults = [.cancelled] + model.codeEntry = code + await model.confirm() + record() + await model.confirm() + record() + try await harness.phone.confirm() + try await harness.phone.poll() + await harness.coordinator.refresh() + record() + + let digits = code.replacingOccurrences(of: " ", with: "") + for text in shown { + XCTAssertFalse(text.contains(digits) || text.contains(code), "the sheet shows the code: \(text)") + } + await model.closeSheet() + } + + func testRemoveAsksForConfirmationFirst() async throws { + try await harness.pairPhone() + let row = try XCTUnwrap(harness.coordinator.devices.first) + model.requestRemoval(row) + XCTAssertEqual(model.removalCandidate, row.id) + XCTAssertEqual(harness.coordinator.devices.count, 1, "asking removes nothing") + XCTAssertEqual(DevicesPresentation.removalWarning(row.displayName), + "This Mac stops trusting “Synthetic Phone” at once. Ask Key tries to tell the device so it can remove this Mac. If it still lists this Mac, remove this Mac on that device too.") + model.cancelRemoval() + await model.confirmRemoval() + XCTAssertEqual(harness.coordinator.devices.count, 1, "a cancelled confirmation removes nothing") + + model.requestRemoval(row) + await model.confirmRemoval() + XCTAssertTrue(harness.coordinator.devices.isEmpty) + XCTAssertNil(model.removalCandidate) + XCTAssertEqual(harness.macSent(.unpairNotice).count, 1) + } + + func testTheSectionReceivesOneModelThatClosesLeftoversOnEachVisit() async throws { + // Settings is rebuilt on every visit; the section only receives the model. + if let shared = VaultViewModel.devicesSettingsModel { + XCTAssertTrue(VaultViewModel.devicesSettingsModel === shared, "one model for the App's coordinator") + } + try await harness.pairPhone() + let row = try XCTUnwrap(harness.coordinator.devices.first) + model.beginRename(row) + model.requestRemoval(row) + await model.sectionOpened() + XCTAssertNil(model.renaming) + XCTAssertNil(model.removalCandidate) + XCTAssertEqual(harness.coordinator.devices.count, 1, "reopening the section removes nothing") + } + + func testRemovingTheLastDeviceAfterAnAccountChangeLetsPairingStart() async throws { + try await harness.pairPhone() + harness.backend.accountState = .available(userRecordName: "_synthetic-account-2") + await harness.coordinator.devicesOpened() + XCTAssertEqual(harness.coordinator.account, .accountChanged) + XCTAssertFalse(model.canPair, "pairing waits until the other account's devices are removed") + + model.requestRemoval(try XCTUnwrap(harness.coordinator.devices.first)) + await model.confirmRemoval() + XCTAssertTrue(harness.coordinator.devices.isEmpty) + XCTAssertEqual(harness.coordinator.account, .available, "the new account becomes the empty list's") + XCTAssertTrue(model.canPair) + } + + func testRenameTestDeliveryAndRowText() async throws { + try await harness.pairPhone() + let row = try XCTUnwrap(harness.coordinator.devices.first) + XCTAssertTrue(DevicesPresentation.rowSummary(row).hasPrefix("iPhone · Paired ")) + XCTAssertTrue(DevicesPresentation.rowReachability(row).text.hasPrefix("Last reached ")) + XCTAssertEqual(DevicesPresentation.rowDeliveryCheck(row), .init(text: "Delivery check answered", isWarning: false)) + + model.beginRename(row) + model.renameDraft = " \u{202E} " + XCTAssertFalse(model.canSaveRename, "a name that cleans to nothing can't be saved") + model.renameDraft = "Work iPhone" + await model.saveRename() + XCTAssertNil(model.renaming) + XCTAssertEqual(harness.coordinator.devices.first?.displayName, "Work iPhone") + + let checks = harness.macSent(.deliveryCheck).count + await model.testDelivery(try XCTUnwrap(harness.coordinator.devices.first)) + XCTAssertEqual(harness.macSent(.deliveryCheck).count, checks + 1) + XCTAssertEqual(DevicesPresentation.rowDeliveryCheck(try XCTUnwrap(harness.coordinator.devices.first)), + .init(text: "Delivery check sent", isWarning: false)) + + harness.backend.accountState = .noAccount + await harness.coordinator.devicesOpened() + XCTAssertFalse(model.canTestDelivery, "no delivery check without the account") + let unreachable = try XCTUnwrap(harness.coordinator.devices.first) + XCTAssertEqual(DevicesPresentation.rowReachability(unreachable), .init(text: "Can't be reached", isWarning: true)) + XCTAssertNil(DevicesPresentation.rowDeliveryCheck(unreachable), "an older check result would contradict it") + + harness.script.managementActive = false + model.beginRename(try XCTUnwrap(harness.coordinator.devices.first)) + model.renameDraft = "Locked Rename" + await model.saveRename() + XCTAssertEqual(harness.coordinator.lastIssue, .managementRequired) + XCTAssertEqual(DevicesPresentation.issueMessage(.managementRequired), + "Credential management is locked. Unlock it, then try again.") + XCTAssertEqual(harness.coordinator.devices.first?.displayName, "Work iPhone") + } + + func testAnUnusableIdentityOffersAResetThatAsksFirst() async throws { + try await harness.pairPhone() + harness.backend.restoreFails = true + await harness.relaunch() + model = DevicesSettingsModel(coordinator: harness.coordinator) + XCTAssertEqual(harness.coordinator.availability, .identityUnusable) + XCTAssertTrue(model.offersReset) + XCTAssertFalse(model.canPair) + XCTAssertEqual(model.notice, .init(text: "This Mac's device keys no longer work, for example after Migration Assistant or a restore. Reset the device identity, then pair your devices again.", isWarning: true)) + + model.requestReset() + XCTAssertTrue(model.confirmingReset) + XCTAssertFalse(harness.library.keys.deviceItems.isEmpty, "asking deletes nothing") + model.cancelReset() + await model.confirmReset() + XCTAssertEqual(harness.coordinator.availability, .identityUnusable, "a cancelled confirmation deletes nothing") + + model.requestReset() + await model.confirmReset() + XCTAssertEqual(harness.coordinator.availability, .ready) + XCTAssertTrue(harness.library.keys.deviceItems.isEmpty, "the reset deletes the keys and the list") + XCTAssertFalse(model.offersReset) + XCTAssertTrue(model.canPair) + } + + func testEachUnavailableStateShowsItsMessage() async throws { + harness.backend.accountState = .noAccount + await model.pairDevice() + XCTAssertEqual(model.sheet?.stage, .failed) + let signedOut = "iCloud is signed out, so paired devices can't be reached. To pair, sign in with the same Apple Account as your iPhone." + XCTAssertEqual(model.sheet?.message, signedOut) + XCTAssertEqual(model.notice, .init(text: signedOut, isWarning: true)) + await model.closeSheet() + XCTAssertTrue(harness.library.keys.deviceItems.isEmpty, "no key is stored without an account") + + harness.backend.isSecureEnclaveAvailable = false + harness.backend.accountState = .available(userRecordName: "_synthetic-account-1") + XCTAssertEqual(model.notice.text, "This Mac has no Secure Enclave, so it can't keep device keys safe and can't pair.", + "said before any attempt") + XCTAssertFalse(model.canPair) + await harness.coordinator.startPairing() + XCTAssertEqual(model.sheet?.message, "This Mac has no Secure Enclave, so it can't keep device keys safe and can't pair.") + await model.closeSheet() + XCTAssertFalse(model.canPair) + + func notice(_ availability: DeviceServicesAvailability, _ account: DeviceAccountStatus = .available, + count: Int = 1, storage: String? = nil) -> DevicesPresentation.Notice { + DevicesPresentation.sectionNotice(availability: availability, account: account, deviceCount: count, + storageFailureMessage: storage) + } + let states: [(DevicesPresentation.Notice, String)] = [ + (notice(.secureEnclaveUnavailable), "This Mac has no Secure Enclave, so it can't keep device keys safe and can't pair."), + (notice(.ready, .noAccount), signedOut), + (notice(.ready, .restricted), "iCloud is restricted on this Mac, for example by Screen Time or a device management profile, so devices can't be reached."), + (notice(.ready, .temporarilyUnavailable), "Ask Key can't reach iCloud right now, so devices can't be reached. Check the connection."), + (notice(.ready, .accountChanged), "This Mac now uses a different Apple Account than the one these devices were paired in, so they can't be reached. Sign back in, or remove them and pair again."), + (notice(.identityUnusable), "This Mac's device keys no longer work, for example after Migration Assistant or a restore. Reset the device identity, then pair your devices again."), + (notice(.listRefused), "The list of paired devices failed its integrity check, so Ask Key won't use it. Reset the device identity, then pair your devices again."), + (notice(.storageUnavailable, storage: "Synthetic keychain message."), "Synthetic keychain message."), + (notice(.ready, count: PairedDeviceList.maximumCount), "8 devices are paired, the most Ask Key allows. Remove one to pair another."), + ] + for (notice, text) in states { + XCTAssertEqual(notice, .init(text: text, isWarning: true)) + } + XCTAssertFalse(notice(.ready).isWarning) + XCTAssertFalse(DevicesPresentation.canPair(availability: .ready, account: .available, + deviceCount: PairedDeviceList.maximumCount, isPairing: false)) + XCTAssertFalse(DevicesPresentation.canPair(availability: .ready, account: .accountChanged, deviceCount: 1, isPairing: false)) + XCTAssertTrue(DevicesPresentation.canPair(availability: .ready, account: .noAccount, deviceCount: 0, isPairing: false), + "signing in again can be retried from Pair a Device…") + } + + func testEveryEndAndStartFailureHasItsOwnMessage() { + let reasons: [PairingEndReason] = [.cancelled, .codeMismatch, .commitmentMismatch, .cancelledByPeer, .undoneByPeer, + .secondRequest, .expired, .deviceLimitReached, .keyConflict, .peerRemoved, + .identityUnusable] + XCTAssertEqual(Set(reasons.map(PairingSheetPresentation.endMessage)).count, reasons.count) + let failures: [PairingStartFailure] = [.managementRequired, .secureEnclaveUnavailable, .accountUnavailable(.noAccount), + .accountUnavailable(.restricted), .iCloudUnavailable, .identityUnusable, + .storageUnavailable, .keyCreationFailed] + XCTAssertEqual(Set(failures.map(PairingSheetPresentation.startFailureMessage)).count, failures.count) + } + + // MARK: Support + + private func reachCodeEntry() async throws -> String { + await model.pairDevice() + guard case .qrCode(let text, _) = try XCTUnwrap(model.sheet).stage else { + XCTFail("the QR code") + throw DeviceMessengerError.notAwaitingConfirmation + } + try await harness.phone.scan(text) + await harness.coordinator.refresh() + try await harness.phone.poll() + await harness.coordinator.refresh() + XCTAssertEqual(model.sheet?.stage, .codeEntry(accepted: false)) + let shownCode = await harness.phone.shownCode() + return try XCTUnwrap(shownCode) + } + + private static func wrong(_ code: String) -> String { + let digits = code.replacingOccurrences(of: " ", with: "") + return String(format: "%06d", (Int(digits)! + 1) % 1_000_000) + } +} + +extension PairingSheetPresentation { + /// Every piece of text the sheet shows, for the check that none carries a code. + var shownText: [String] { + var texts = [title, message, notice, feedback].compactMap { $0 } + if let peer { texts += [peer.displayName, Self.peerPlatform(peer)] } + return texts + } +} diff --git a/Tests/AskKeyAppTests/DevicesSettingsRenderTests.swift b/Tests/AskKeyAppTests/DevicesSettingsRenderTests.swift new file mode 100644 index 0000000..477249b --- /dev/null +++ b/Tests/AskKeyAppTests/DevicesSettingsRenderTests.swift @@ -0,0 +1,99 @@ +import AppKit +import AskKeyTestSupport +import SwiftUI +import XCTest +@testable import AskKeyAppKit +@testable import AskKeyDevices + +/// Renders the Devices section and every pairing sheet step offscreen in +/// both languages, so a layout that fails to draw or a missing translation +/// fails here before CI captures the windows. +@MainActor +final class DevicesSettingsRenderTests: AskKeyAppTestCase { + private var directory: URL! + + override func setUp() async throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("AskKeyDeviceRenders-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDown() async throws { + try? FileManager.default.removeItem(at: directory) + } + + func testEveryPairingStepAndTheListRenderInBothLanguages() async throws { + let previous = AppLanguage.current + defer { AppLanguage.current = previous } + for language in ["en", "zh-Hans"] { + AppLanguage.current = language + let harness = try DeviceCoordinatorHarness() + defer { try? harness.close() } + await harness.coordinator.launch() + let model = DevicesSettingsModel(coordinator: harness.coordinator) + + await model.pairDevice() + guard case .qrCode(let text, _) = try XCTUnwrap(model.sheet).stage else { return XCTFail("the QR code") } + try render(DevicePairingSheet(model: model), as: "\(language)-qr-code") + try await harness.phone.scan(text) + await harness.coordinator.refresh() + try render(DevicePairingSheet(model: model), as: "\(language)-waiting") + try await harness.phone.poll() + await harness.coordinator.refresh() + let entry = try render(DevicePairingSheet(model: model), as: "\(language)-code-entry") + let fields = Self.textFields(in: entry) + XCTAssertEqual(fields.count, 1) + XCTAssertNil(fields.first?.contentType, "the code field is not a one-time-code or autofill field") + XCTAssertNotEqual(fields.first?.contentType, .oneTimeCode) + + model.codeEntry = "000 000" + await model.confirm() + try render(DevicePairingSheet(model: model), as: "\(language)-wrong-code") + let shown = await harness.phone.shownCode() + harness.script.authenticationResults = [.cancelled] + model.codeEntry = try XCTUnwrap(shown) + await model.confirm() + try render(DevicePairingSheet(model: model), as: "\(language)-authentication-cancelled") + await model.confirm() + try await harness.phone.confirm() + try await harness.phone.poll() + await harness.coordinator.refresh() + try render(DevicePairingSheet(model: model), as: "\(language)-paired") + await model.closeSheet() + try await harness.phone.poll() + await harness.coordinator.refresh() + + try render(DevicesSettingsSection(model: model).frame(width: 712), as: "\(language)-list") + model.requestRemoval(try XCTUnwrap(harness.coordinator.devices.first)) + try render(DevicesSettingsSection(model: model).frame(width: 712), as: "\(language)-remove") + model.cancelRemoval() + harness.backend.accountState = .noAccount + await harness.coordinator.devicesOpened() + try render(DevicesSettingsSection(model: model).frame(width: 712), as: "\(language)-icloud-signed-out") + } + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: directory.path).count, 18) + } + + @discardableResult + private func render(_ view: V, as name: String) throws -> NSView { + _ = NSApplication.shared + let hosting = NSHostingView(rootView: view) + hosting.appearance = NSAppearance(named: .aqua) + hosting.frame = CGRect(origin: .zero, size: hosting.fittingSize) + hosting.layoutSubtreeIfNeeded() + hosting.display() + XCTAssertGreaterThan(hosting.bounds.width, 100, name) + XCTAssertGreaterThan(hosting.bounds.height, 60, name) + let representation = try XCTUnwrap(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) + hosting.cacheDisplay(in: hosting.bounds, to: representation) + let data = try XCTUnwrap(representation.representation(using: .png, properties: [:])) + try data.write(to: directory.appendingPathComponent("\(name).png"), options: .atomic) + XCTAssertGreaterThan(data.count, 5_000, "\(name) did not render") + return hosting + } + + private static func textFields(in view: NSView) -> [NSTextField] { + let own = (view as? NSTextField).map { [$0] }?.filter(\.isEditable) ?? [] + return own + view.subviews.flatMap(textFields(in:)) + } +} diff --git a/Tests/AskKeyAppTests/ManagementAuthenticationProcessTests.swift b/Tests/AskKeyAppTests/ManagementAuthenticationProcessTests.swift index 4253d10..6f44c87 100644 --- a/Tests/AskKeyAppTests/ManagementAuthenticationProcessTests.swift +++ b/Tests/AskKeyAppTests/ManagementAuthenticationProcessTests.swift @@ -84,6 +84,25 @@ final class ManagementAuthenticationProcessTests: AskKeyAppTestCase { } } + /// Pairing confirmation asks with its own reason (#288), which the + /// authentication subprocess accepts and shows in either language. + func testPackagedSubprocessAcceptsAndDescribesThePairingReason() throws { + let action = ManagementAuthenticationAction.pairDevice + XCTAssertEqual(action.reasonKey, "Pair a device with this Mac") + XCTAssertNotEqual(action.reasonKey, CredentialManagementCopy.manageReason) + XCTAssertTrue(ManagementAuthenticationSubprocess.accepts(reasonKey: action.reasonKey, argumentCount: 0)) + XCTAssertFalse(ManagementAuthenticationSubprocess.accepts(reasonKey: action.reasonKey, argumentCount: 1)) + let chinese = try describePackagedAuthentication( + presentation: ManagementAuthenticationPresentation(reasonKey: action.reasonKey, language: "zh-Hans") + ) + XCTAssertEqual(chinese.title, "请旨") + XCTAssertEqual(chinese.reason, "确认将设备与这台 Mac 配对") + let english = try describePackagedAuthentication( + presentation: ManagementAuthenticationPresentation(reasonKey: action.reasonKey, language: "en") + ) + XCTAssertEqual(english.reason, "Pair a device with this Mac") + } + func testAuthenticationArgumentsRejectUnknownDuplicateAndMixedModes() { let presentation = ManagementAuthenticationPresentation( reasonKey: CredentialManagementCopy.manageReason, diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift index 33a4e27..bc33cc8 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift @@ -343,6 +343,72 @@ final class ScreenshotE2ETests: E2EBaseCase { click("backup-import-done") } + func testDevicesScreensInEnglish() throws { + try captureDevicesScreens(language: "en", tag: "en", firstNumber: 30) + } + + func testDevicesScreensInChinese() throws { + try captureDevicesScreens(language: "zh-Hans", tag: "zh", firstNumber: 35) + } + + /// Settings → Devices on the E2E App's fake device services (#288): the list with one paired + /// iPhone, the pairing sheet's QR code, waiting and code entry steps, and iCloud signed out. The + /// runner clicks Pair a Device… itself, so a macOS release on which that click starts nothing + /// fails here. The fixture plays the iPhone, including the person typing its code, which never + /// leaves the App. The Devices texts are found by identifier: after the code field had focus, + /// snapshots of the whole window can hang on the text cursor's remote element on the CI runner. + private func captureDevicesScreens(language: String, tag: String, firstNumber: Int) throws { + func name(_ offset: Int, _ label: String) -> String { String(format: "%02d-%@-%@", firstNumber + offset, tag, label) } + func fixture(_ command: String) throws { + try sendFixtureCommand(command) + let report = try waitForEvidence("device-\(command).json") + XCTAssertEqual(report["ok"] as? Bool, true, "\(command): \(report)") + } + func waitUntilShown(_ identifier: String, _ text: String) { + let element = app.staticTexts[identifier].firstMatch + let shown = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in + element.exists && (element.label.contains(text) || (element.value as? String)?.contains(text) == true) + }, object: nil) + XCTAssertEqual(XCTWaiter.wait(for: [shown], timeout: 8), .completed, "Missing \(text)") + } + app.launchEnvironment["ASKKEY_E2E_SCENARIO"] = "screenshots-devices" + app.launchEnvironment["ASKKEY_E2E_LANGUAGE"] = language + app.launch() + addTeardownBlock { [app] in app?.terminate() } // a hung query must not also hang the teardown + moveManagementAboveDock() + click("unlock-management") + click("sidebar-settings") + let page = app.scrollViews["settings-page"].firstMatch + let pair = app.buttons["settings-pair-device"].firstMatch + XCTAssertTrue(pair.waitForExistence(timeout: 8)) + for _ in 0..<6 where !pair.isHittable { page.scroll(byDeltaX: 0, deltaY: -300) } + click("settings-pair-device") + let opened = app.windows["settings"].sheets.firstMatch.waitForExistence(timeout: 8) + try fixture("trace") // T6 TEMP #298, revert before merge: the App's trace of that click + let trace = (try? String(contentsOf: runDirectory.appendingPathComponent("device-pairing-trace.json"), encoding: .utf8)) ?? "none" + print("T6TRACE \(language) sheet=\(opened)\n\(trace)") + XCTAssertTrue(opened, "Pair a Device… opened no sheet. Trace: \(trace)") + XCTAssertTrue(app.descendants(matching: .any)["devices-pairing-qr"].waitForExistence(timeout: 8)) + captureSheet(name(1, "devices-pairing-qr-code")) + try fixture("phone-scan") + XCTAssertTrue(app.descendants(matching: .any)["devices-pairing-waiting"].waitForExistence(timeout: 8)) + captureSheet(name(2, "devices-pairing-waiting")) + try fixture("phone-reveal") + XCTAssertTrue(app.textFields["devices-pairing-code"].waitForExistence(timeout: 8)) + captureSheet(name(3, "devices-pairing-code-entry")) + try fixture("phone-complete") + click("devices-pairing-done") + try fixture("phone-poll") + let rename = app.buttons["devices-rename"].firstMatch + XCTAssertTrue(rename.waitForExistence(timeout: 8)) + for _ in 0..<6 where !rename.isHittable { page.scroll(byDeltaX: 0, deltaY: -300) } + waitUntilShown("devices-row-reachability", language == "en" ? "Last reached" : "\u{4E0A}\u{6B21}\u{8054}\u{7CFB}") + captureWindow(name(0, "devices-list")) + try fixture("account-signed-out") + waitUntilShown("settings-devices-notice", language == "en" ? "iCloud is signed out" : "iCloud \u{5DF2}\u{9000}\u{51FA}\u{767B}\u{5F55}") + captureWindow(name(4, "devices-icloud-signed-out")) + } + /// Saves "Staging API" through the first-run editor, then unlocks /// management and opens Settings. private func createFirstCredential(value: String) { @@ -358,7 +424,15 @@ final class ScreenshotE2ETests: E2EBaseCase { XCTAssertTrue(app.buttons["settings-agent-access"].waitForExistence(timeout: 8)) } - /// The Backup section sits below the fold of Settings. + /// The Settings window alone, without querying the App's other windows. + private func captureWindow(_ name: String) { + let attachment = XCTAttachment(screenshot: app.windows["settings"].firstMatch.screenshot()) + attachment.name = name + ".png" + attachment.lifetime = .keepAlways + add(attachment) + } + + /// The Backup and Devices sections sit below the fold of Settings. private func openBackupSetting(_ identifier: String) { let button = app.buttons[identifier].firstMatch XCTAssertTrue(button.waitForExistence(timeout: 8), "Missing control: \(identifier)") diff --git a/Tests/AskKeyTestSupport/E2EAppRuntime.swift b/Tests/AskKeyTestSupport/E2EAppRuntime.swift index f9ef3f6..68cd4e0 100644 --- a/Tests/AskKeyTestSupport/E2EAppRuntime.swift +++ b/Tests/AskKeyTestSupport/E2EAppRuntime.swift @@ -19,7 +19,10 @@ package enum E2EAppRuntime { makeViewModel: makeViewModel, prepareServices: configureE2EAuthentication, didStart: startScenario, - willStop: stop + willStop: stop, + // Only the Devices screenshots show the Devices section; every + // other run stays a build without CloudKit. + makeDeviceServices: E2EDeviceScenario.isRequested(scenario) ? E2EDeviceScenario.makeBackend : nil ) } @@ -203,7 +206,11 @@ package enum E2EAppRuntime { static func startScenario() { driver = E2EBrokerScenario(directory: runDirectory, control: controlDirectory, scenario: scenario) driver?.start() + if E2EDeviceScenario.isRequested(scenario) { E2EDeviceScenario.start(control: controlDirectory) } } - static func stop() { driver?.stop() } + static func stop() { + E2EDeviceScenario.stop() + driver?.stop() + } } diff --git a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift new file mode 100644 index 0000000..55aa2f1 --- /dev/null +++ b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift @@ -0,0 +1,165 @@ +import AskKeyAppKit +import AskKeyDevices +import Foundation +import Observation + +/// The other side of the Devices screenshots (#288): the E2E App's fake +/// device services and a simulated iPhone in the same process, on the +/// factory from #287. The runner clicks the App's real controls and plays +/// the iPhone and the iCloud account through one-shot fixture commands. +/// The iPhone's code never leaves this process: the step that enters it +/// plays the person typing it, and nothing writes it anywhere. +@MainActor +enum E2EDeviceScenario { + static let scenarioPrefix = "screenshots-devices" + static let phoneName = "Synthetic iPhone" + static let commands = ["phone-scan", "phone-reveal", "phone-complete", "phone-poll", "account-signed-out", "trace"] + + private static var backend: FakeDeviceServicesBackend? + private static var phone: SimulatedPairingPhone? + private static var task: Task? + private static var transitions: [String] = [] + private static var control: URL? + + static func isRequested(_ scenario: String) -> Bool { + scenario.hasPrefix(scenarioPrefix) + } + + /// The factory the E2E App passes as `makeDeviceServices`. + static func makeBackend() -> any DeviceServicesBackend { + let backend = FakeDeviceServicesBackend(account: .available(userRecordName: "_synthetic-e2e-account")) + self.backend = backend + phone = SimulatedPairingPhone(transport: backend.transport, clock: backend.clock) + return backend + } + + /// Runs each command once when its file appears, then removes the file + /// and writes `device-.json` with the outcome. + static func start(control: URL) { + self.control = control + recordPairingTransitions(control: control) + task = Task { @MainActor in + while !Task.isCancelled { + for command in commands { + let file = control.appendingPathComponent("command-\(command).txt") + guard FileManager.default.fileExists(atPath: file.path) else { continue } + try? FileManager.default.removeItem(at: file) + var report: [String: Any] = ["command": command] + do { + try await run(command) + report["ok"] = true + } catch { + report["ok"] = false + report["error"] = String(describing: error) + } + if let data = try? JSONSerialization.data(withJSONObject: report, options: [.sortedKeys]) { + try? data.write(to: control.appendingPathComponent("device-\(command).json"), options: .atomic) + } + } + try? await Task.sleep(for: .milliseconds(100)) + } + } + } + + static func stop() { + task?.cancel() + task = nil + } + + /// A pairing state by case name; the end and start-failure reasons in + /// full, which carry no code, QR payload or name. + private static func describe(_ state: PairingSheetState) -> String { + switch state { + case .ended, .failed: return String(describing: state) + default: return String(String(describing: state).prefix { $0 != "(" }) + } + } + + /// Writes `device-transitions.json` with the coordinator's state at + /// launch and after each change the Devices section reads: pairing by + /// case name only (the QR payload holds the pairing secret), + /// availability, account, identity and the number of devices. A failed + /// capture then shows where the session went. + private static func recordPairingTransitions(control: URL, reason: String = "launch") { + guard let coordinator = DeviceServicesHost.coordinator else { + transitions.append("\(Date().timeIntervalSince1970) no device coordinator") + writeTransitions(control: control) + return + } + let state = coordinator.pairing.map(describe) ?? "none" + transitions.append("\(Date().timeIntervalSince1970) \(reason): pairing=\(state) availability=\(coordinator.availability) " + + "account=\(coordinator.account) identity=\(coordinator.hasIdentity) devices=\(coordinator.devices.count)") + writeTransitions(control: control) + withObservationTracking { + _ = (coordinator.pairing, coordinator.availability, coordinator.account, coordinator.hasIdentity, + coordinator.devices.count) + } onChange: { + Task { @MainActor in recordPairingTransitions(control: control, reason: "change") } + } + } + + private static func writeTransitions(control: URL) { + let report = ["transitions": transitions] + if let data = try? JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted]) { + try? data.write(to: control.appendingPathComponent("device-transitions.json"), options: .atomic) + } + } + + private static func run(_ command: String) async throws { + guard let coordinator = DeviceServicesHost.coordinator, let phone, let backend else { + throw E2EDeviceScenarioError.notInstalled + } + switch command { + case "phone-scan": + guard case .showingQRCode(let text, _) = coordinator.pairing else { throw E2EDeviceScenarioError.noQRCode } + try await phone.scan(text, name: phoneName) + case "phone-reveal": + // The Mac's challenge follows its next poll; the reveal follows the challenge. + try await repeatUntil { try await phone.poll(); return await phone.shownCode() != nil } + case "phone-complete": + // The person types the code the iPhone shows and confirms on the + // Mac (system authentication is simulated), then on the iPhone. + guard let code = await phone.shownCode() else { throw E2EDeviceScenarioError.noCode } + await coordinator.enterPairingCode(code) + await coordinator.confirmPairing() + try await phone.confirm() + try await repeatUntil { + try await phone.poll() + await coordinator.refresh() + if case .paired = coordinator.pairing { return true } + return false + } + case "phone-poll": + // The iPhone app opens and answers the delivery check. + try await repeatUntil { + try await phone.poll() + await coordinator.refresh() + if case .reached = coordinator.devices.first?.reachability { return true } + return false + } + case "trace": + // T6 TEMP #298, revert before merge: what Pair a Device… did on the runner's click. + guard let control else { throw E2EDeviceScenarioError.notInstalled } + let data = try JSONSerialization.data(withJSONObject: ["trace": DevicesPairingTrace.lines], options: [.prettyPrinted]) + try data.write(to: control.appendingPathComponent("device-pairing-trace.json"), options: .atomic) + case "account-signed-out": + backend.accountState = .noAccount + backend.signalAccountChange() + default: + throw E2EDeviceScenarioError.unknownCommand + } + } + + /// Repeats `step` for up to ten seconds until it reports success. + private static func repeatUntil(_ step: () async throws -> Bool) async throws { + for _ in 0..<50 { + if try await step() { return } + try await Task.sleep(for: .milliseconds(200)) + } + throw E2EDeviceScenarioError.timedOut + } +} + +enum E2EDeviceScenarioError: Error { + case notInstalled, noQRCode, noCode, timedOut, unknownCommand +} diff --git a/docs/glossary.md b/docs/glossary.md index 22a0152..59e5d0b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -37,6 +37,10 @@ The Chinese UI column quotes labels or relevant phrases from [Localizable.xcstri | Recycle bin | 回收站 | Deleted credentials retained for 30 days and unavailable to agents. Restore and permanent deletion are App management operations. | | Backup file | 备份文件 | One `.askkeybackup` file holding every credential in the library, including Hidden and recycled ones, usage instructions and private notes, encrypted under its recovery key. Importing it adds credentials and never deletes or overwrites; see [ADR 0012](adr/0012-encrypted-backup-export-and-import.md). | | Recovery key | 恢复密钥 | The key generated for one backup file, 28 characters in seven groups of four, shown once when the backup is made. Ask Key never stores it. Nobody can open the file without it, and anyone holding both can read every credential in the file. | +| Device | 设备 | Another Ask Key install the Mac trusts after pairing; in stage 2 an iPhone running the pairing-only app. Its platform and name are what it says about itself and are never shown as verified. See [ADR 0013](adr/0013-device-identity-and-pairing.md). | +| Pair | 配对; 配对设备… | Adding a device to this Mac's list: the Mac shows a QR code, the iPhone scans it, and the person enters the iPhone's pairing code on the Mac and confirms with system authentication. Needs the management session; agents cannot pair, list or remove devices. | +| Pairing code | 配对码 | Only the six digits the iPhone shows on its Ask Key pairing screen, typed on the Mac. The Mac never shows it, and Ask Key never sends it in a notification or a message. The image the iPhone scans is always the QR code (二维码), never a pairing code. | +| Delivery check | 送达检查; 测试送达; 还没联系上 | A message the Mac sends to a paired device to confirm it can be reached through iCloud. Until the first check succeeds, the device shows as "Not reached yet" (还没联系上). | | Client adapter | — | The boundary that checks, previews, applies, verifies and rolls back a supported client's MCP configuration. Current clients are Claude Code, Codex, Cursor and Grok CLI; adapters share the Broker's security model. | | Discovery hook | 凭证查询 | A reminder to consult the catalog before relevant commands. Discovery does not select credentials, approve requests or bypass the Broker. | diff --git a/docs/testing.md b/docs/testing.md index a3934fd..e368b19 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -123,6 +123,21 @@ The backup flow (#274) adds eight captures with synthetic credentials, in Englis 29-zh-backup-import-result.png ``` +The Devices section (#288) adds ten captures in English (`30`–`34`) and Simplified Chinese (`35`–`39`). The `screenshots-devices` scenario gives the E2E App fake device services with software keys, an in-memory transport and a simulated iPhone named "Synthetic iPhone" (`Tests/AskKeyTestSupport/E2EDeviceScenario.swift`); every other scenario keeps the build without CloudKit and shows no Devices section. The runner clicks the App's controls; fixture commands play the iPhone scanning, revealing, entering its code on the Mac and confirming, answering the delivery check, and the iCloud account signing out. The iPhone's code never leaves the App process. Like the backup captures, take them from the attachments of `basic-flows.xcresult`: + +```text +30-en-devices-list.png the list with one paired iPhone +31-en-devices-pairing-qr-code.png the pairing sheet with its QR code +32-en-devices-pairing-waiting.png waiting for the iPhone's reveal +33-en-devices-pairing-code-entry.png the field for the code the iPhone shows +34-en-devices-icloud-signed-out.png iCloud signed out, the device unreachable +35-zh-devices-list.png +36-zh-devices-pairing-qr-code.png +37-zh-devices-pairing-waiting.png +38-zh-devices-pairing-code-entry.png +39-zh-devices-icloud-signed-out.png +``` + An optional capture failure is diagnostic: inspect the screenshot step, its log and xcresult, and the exported subset. A green required-flow job alone does not prove screenshot completeness; #146 acceptance also requires every PNG listed above in the artifact. Delete downloaded evidence after review. Desktop tests exercise shared App/Broker/helper behavior, but system authentication and external client boundaries use isolated fixtures. Passing them does not validate real Touch ID, production keychain access, a real client task, signing/notarization, or a release. Per [ADR 0006](adr/0006-release-process.md), `Local` and `Release` builds run [release-gate.py](../scripts/release-gate.py): the checkout must be clean, `HEAD` must be reachable from `origin/main` after fetching, and the latest push-to-main `CI` run for that exact commit and both required jobs (`build-and-test`, `basic-ui-flows`) must succeed. Jobs are checked for the run's latest attempt. `Release` also requires the tag `v` to point at `HEAD`; signing identities, notarization, and explicit maintainer publication approval remain required by the release process.