From 22120f66e4490a89937e72981c7b8bf1e37f8977 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Tue, 10 Nov 2026 16:03:47 +0800 Subject: [PATCH 01/21] Add the Devices section to Mac Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings gains a Devices section between Backup and Data, shown only when the device coordinator is configured (a build with CloudKit). Pair a Device… opens a sheet that shows the QR code drawn with Core Image and a countdown, then the device that scanned it (its platform marked as not verified) while the Mac waits for the iPhone's reveal, then a six-digit field for the code the iPhone shows. Confirm checks the entry; a wrong or malformed entry asks for nothing, and an accepted code asks for system authentication at once. A cancelled or failed prompt keeps the accepted entry, and Confirm asks again without a new entry. The field is cleared once the code is accepted, is not a one-time-code field and does not cut an entry short. The sheet never shows the Mac's code: no state it reads carries one. Expiry, three wrong codes, a second responder, cancellation and start failures each have their own message, with Pair Again where it helps. The list shows each device's name, platform, paired date, last reached and delivery check state, with Rename, Test Delivery and Remove; Remove and Reset Device Identity… confirm inline first. The section explains each unavailable state: no Secure Enclave, iCloud signed out, restricted or unavailable, an account change, an unusable identity or refused list (with the reset), and the limit of 8. Pairing confirmation now asks for system authentication with its own reason, "Pair a device with this Mac", which the authentication subprocess accepts. Every new string is in the catalog in English and Simplified Chinese, with the four device keychain conflict messages and neutral text for push.message.title and push.message.body. The glossary adds Device, Pair, Pairing code and Delivery check. The E2E App gets fake device services and a simulated iPhone only in the screenshots-devices scenario, and the screenshot suite captures the list, the QR code, waiting, code entry and iCloud signed out in English (30-34) and Simplified Chinese (35-39). Closes #288 --- Sources/AskKeyAppKit/AppLanguage.swift | 1 + .../Devices/DeviceCoordinator+Pairing.swift | 2 +- .../ManagementAuthenticationProcess.swift | 3 +- .../Resources/Localizable.xcstrings | 672 ++++++++++++++++++ .../AskKeyAppKit/VaultViewModel+Devices.swift | 10 + .../Views/Settings/DevicePairingSheet.swift | 194 +++++ .../Views/Settings/DevicesPresentation.swift | 152 ++++ .../Views/Settings/DevicesSettingsModel.swift | 216 ++++++ .../Settings/DevicesSettingsSection.swift | 219 ++++++ .../Views/Settings/FrozenSettingsPage.swift | 1 + .../Settings/PairingSheetPresentation.swift | 191 +++++ .../AppLanguageCatalogTests.swift | 42 ++ .../DeviceCoordinatorPairingTests.swift | 2 +- .../DevicesSettingsModelTests.swift | 362 ++++++++++ .../DevicesSettingsRenderTests.swift | 99 +++ ...ManagementAuthenticationProcessTests.swift | 19 + Tests/AskKeyE2ETests/ScreenshotE2ETests.swift | 51 +- Tests/AskKeyTestSupport/E2EAppRuntime.swift | 11 +- .../AskKeyTestSupport/E2EDeviceScenario.swift | 116 +++ docs/glossary.md | 4 + docs/testing.md | 15 + 21 files changed, 2376 insertions(+), 6 deletions(-) create mode 100644 Sources/AskKeyAppKit/VaultViewModel+Devices.swift create mode 100644 Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift create mode 100644 Sources/AskKeyAppKit/Views/Settings/DevicesPresentation.swift create mode 100644 Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift create mode 100644 Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift create mode 100644 Sources/AskKeyAppKit/Views/Settings/PairingSheetPresentation.swift create mode 100644 Tests/AskKeyAppTests/DevicesSettingsModelTests.swift create mode 100644 Tests/AskKeyAppTests/DevicesSettingsRenderTests.swift create mode 100644 Tests/AskKeyTestSupport/E2EDeviceScenario.swift 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..0522237 100644 --- a/Sources/AskKeyAppKit/Devices/DeviceCoordinator+Pairing.swift +++ b/Sources/AskKeyAppKit/Devices/DeviceCoordinator+Pairing.swift @@ -141,7 +141,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..e303e66 100644 --- a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings +++ b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings @@ -16823,6 +16823,678 @@ } } } + }, + "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 and pairing can't start. 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 and pairing can't start. 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 and pairing can't start. 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 also tells the device, which removes this Mac the next time it connects.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This Mac stops trusting “%@” at once. Ask Key also tells the device, which removes this Mac the next time it connects." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "这台 Mac 会立即不再信任“%@”。请旨也会通知这台设备,它下次联网时会移除这台 Mac。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This Mac stops trusting “%@” at once. Ask Key also tells the device, which removes this Mac the next time it connects.!!]" } } + } + }, + "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.!!]" } } + } } }, "version": "1.0" diff --git a/Sources/AskKeyAppKit/VaultViewModel+Devices.swift b/Sources/AskKeyAppKit/VaultViewModel+Devices.swift new file mode 100644 index 0000000..16f592d --- /dev/null +++ b/Sources/AskKeyAppKit/VaultViewModel+Devices.swift @@ -0,0 +1,10 @@ +import Foundation + +extension VaultViewModel { + /// The model behind Settings → Devices (ADR 0013 → Mac App), over the + /// App's one device coordinator. Nil in processes without device + /// services; like a build without CloudKit, they show no Devices section. + static func makeDevicesSettingsModel() -> DevicesSettingsModel? { + DeviceServicesHost.coordinator.map(DevicesSettingsModel.init(coordinator:)) + } +} diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift b/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift new file mode 100644 index 0000000..4faa1b4 --- /dev/null +++ b/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift @@ -0,0 +1,194 @@ +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 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): + cancelButton + TimelineView(.periodic(from: .now, by: 1)) { context in + 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..fbedbfa --- /dev/null +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesPresentation.swift @@ -0,0 +1,152 @@ +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 and pairing can't start. 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)) + } + + /// The reachability and the last delivery check, as one status line. + static func rowStatus(_ row: PairedDeviceRow) -> Notice { + var parts: [String] + var isWarning = false + switch row.reachability { + case .notReachedYet: + parts = [appLocalized("Not reached yet")] + case .reached(let date): + parts = [appLocalizedFormat("Last reached %@", self.date(date, time: true))] + case .unreachable: + // Why is said above the list; an older check result would only contradict it. + return Notice(text: appLocalized("Can't be reached"), isWarning: true) + } + switch row.lastDeliveryCheck { + case .pending: + parts.append(appLocalized("Delivery check sent")) + case .replied: + parts.append(appLocalized("Delivery check answered")) + case .noReply: + parts.append(appLocalized("Delivery check got no reply")) + isWarning = true + case nil: + break + } + return Notice(text: parts.joined(separator: " · "), isWarning: isWarning) + } + + 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 also tells the device, which removes this Mac the next time it connects.", 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..eb33287 --- /dev/null +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift @@ -0,0 +1,216 @@ +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: coordinator.availability, account: coordinator.account, + deviceCount: coordinator.devices.count, + storageFailureMessage: coordinator.storageFailureMessage) + } + + var canPair: Bool { + DevicesPresentation.canPair(availability: coordinator.availability, account: coordinator.account, + deviceCount: coordinator.devices.count, isPairing: coordinator.pairing != nil) + } + + 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 + } + } + + func sectionOpened() async { + await coordinator.devicesOpened() + } + + func pairDevice() async { + guard canPair else { return } + codeEntry = "" + await coordinator.startPairing() + } + + /// **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: coordinator.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)) + guard let image = CIContext().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) + } + + // 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() + } +} diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift new file mode 100644 index 0000000..21fddc2 --- /dev/null +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift @@ -0,0 +1,219 @@ +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 { + @State private var model: DevicesSettingsModel? + + @MainActor + init() { + self.init(model: VaultViewModel.makeDevicesSettingsModel()) + } + + init(model: DevicesSettingsModel?) { + _model = State(initialValue: 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 { + 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) + .disabled(model.confirmingReset) + .accessibilityIdentifier("settings-reset-device-identity") + } else { + Button(appLocalized("Pair a Device…")) { Task { await model.pairDevice() } } + .buttonStyle(.secondaryAction) + .disabled(!model.canPair) + .accessibilityIdentifier("settings-pair-device") + } + } + 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") + } + } + .task { await model.sectionOpened() } + .sheet(isPresented: Binding( + get: { model.isSheetPresented }, + set: { presented in + if !presented { Task { await model.closeSheet() } } + } + )) { + DevicePairingSheet(model: model) + } + } +} + +/// 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 isRenaming = model.renaming == row.id + let status = DevicesPresentation.rowStatus(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) + Text(status.text) + .font(Theme.Fonts.secondary) + .foregroundStyle(status.isWarning ? Theme.warning : Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + 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) + .disabled(!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) + .disabled(!model.canTestDelivery) + .accessibilityIdentifier("devices-test-delivery") + Button(appLocalized("Remove"), role: .destructive) { model.requestRemoval(row) } + .buttonStyle(.irreversibleAction) + .disabled(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") + } + } + } + } +} + +/// 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) + } +} diff --git a/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift b/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift index 79bcb8b..52644be 100644 --- a/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift +++ b/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift @@ -35,6 +35,7 @@ struct FrozenSettingsPage: View { agentAccessSection generalSection BackupSettingsSection() + DevicesSettingsSection() dataSection } .padding(Theme.Spacing.xxl) diff --git a/Sources/AskKeyAppKit/Views/Settings/PairingSheetPresentation.swift b/Sources/AskKeyAppKit/Views/Settings/PairingSheetPresentation.swift new file mode 100644 index 0000000..be3fe65 --- /dev/null +++ b/Sources/AskKeyAppKit/Views/Settings/PairingSheetPresentation.swift @@ -0,0 +1,191 @@ +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("Enter the Pairing Code") + message = Self.codeInstruction + if isAuthenticating { + feedbackText = appLocalized("Waiting for system authentication…") + feedbackIsWarning = false + } else if let failure = Self.authenticationFeedback(feedback) { + feedbackText = failure + } else { + feedbackText = appLocalized("The code matches. Confirm to finish pairing; Ask Key asks for system authentication.") + feedbackIsWarning = false + } + 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 + } + + /// 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 + } + + 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..2a1fa15 --- /dev/null +++ b/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift @@ -0,0 +1,362 @@ +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.makeDevicesSettingsModel()?.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?.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 also tells the device, which removes this Mac the next time it connects.") + 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 testRenameTestDeliveryAndRowText() async throws { + try await harness.pairPhone() + let row = try XCTUnwrap(harness.coordinator.devices.first) + XCTAssertTrue(DevicesPresentation.rowSummary(row).hasPrefix("iPhone · Paired ")) + XCTAssertTrue(DevicesPresentation.rowStatus(row).text.hasPrefix("Last reached ")) + XCTAssertTrue(DevicesPresentation.rowStatus(row).text.hasSuffix(" · Delivery check answered")) + + 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) + XCTAssertTrue(DevicesPresentation.rowStatus(try XCTUnwrap(harness.coordinator.devices.first)).text + .hasSuffix(" · Delivery check sent")) + + harness.backend.accountState = .noAccount + await harness.coordinator.devicesOpened() + XCTAssertFalse(model.canTestDelivery, "no delivery check without the account") + XCTAssertEqual(DevicesPresentation.rowStatus(try XCTUnwrap(harness.coordinator.devices.first)), + .init(text: "Can't be reached", isWarning: true)) + + 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 and pairing can't start. 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") + await model.pairDevice() + 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) + } +} 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..707d302 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift @@ -343,6 +343,55 @@ 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 fixture plays the iPhone, + /// including the person typing its code, which never leaves the App. + 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)") + } + app.launchEnvironment["ASKKEY_E2E_SCENARIO"] = "screenshots-devices" + app.launchEnvironment["ASKKEY_E2E_LANGUAGE"] = language + app.launch() + moveManagementAboveDock() + click("unlock-management") + click("sidebar-settings") + openBackupSetting("settings-pair-device") + 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 settings = app.windows["settings"].scrollViews.containing(.button, identifier: "settings-pair-device").firstMatch + for _ in 0..<4 { settings.scroll(byDeltaX: 0, deltaY: -600) } + waitForText(language == "en" ? "Last reached" : "\u{4E0A}\u{6B21}\u{8054}\u{7CFB}", containing: true) + capture(name(0, "devices-list")) + try fixture("account-signed-out") + waitForText(language == "en" ? "iCloud is signed out" : "iCloud \u{5DF2}\u{9000}\u{51FA}\u{767B}\u{5F55}", + containing: true) + capture(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 +407,7 @@ final class ScreenshotE2ETests: E2EBaseCase { XCTAssertTrue(app.buttons["settings-agent-access"].waitForExistence(timeout: 8)) } - /// The Backup section sits below the fold of Settings. + /// 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..51f8b6a --- /dev/null +++ b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift @@ -0,0 +1,116 @@ +import AskKeyAppKit +import AskKeyDevices +import Foundation + +/// 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"] + + private static var backend: FakeDeviceServicesBackend? + private static var phone: SimulatedPairingPhone? + private static var task: Task? + + 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) { + 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 + } + + 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 "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. From 71e0faa66442b45e4f8ea7c98f5ba64e4c8b39ab Mon Sep 17 00:00:00 2001 From: sudoHG Date: Tue, 10 Nov 2026 16:28:48 +0800 Subject: [PATCH 02/21] Address the first review round of the Devices section - The accepted-code step has its own title and message, Confirm Pairing, instead of repeating the entry instruction without a field. - Unavailable Devices actions are dimmed as well as disabled, because the shared secondary button styles draw the same when disabled. - A Mac without a Secure Enclave says so before the first attempt. - Removing the last device after an account change checks the account again, so pairing in the new account can start. - The device row shows the delivery check on its own line, and none while the device can't be reached. - Each Devices subview subscribes to the language itself, so switching language updates the section and the sheet. - Copy: the signed-out line no longer says pairing can't start while Pair a Device stays available; the removal confirmation states the local guarantee and asks to remove this Mac on the device if it still shows it; Chinese wording fixes for the code warning, the restricted and undone messages and the removed-device issue. - The test-only shownText helper moves to the test target. --- .../Resources/Localizable.xcstrings | 32 ++++++++----- .../Views/Settings/DevicePairingSheet.swift | 1 + .../Views/Settings/DevicesPresentation.swift | 36 +++++++------- .../Views/Settings/DevicesSettingsModel.swift | 19 ++++++-- .../Settings/DevicesSettingsSection.swift | 35 ++++++++++---- .../Settings/PairingSheetPresentation.swift | 15 ++---- .../DevicesSettingsModelTests.swift | 47 +++++++++++++++---- 7 files changed, 120 insertions(+), 65 deletions(-) diff --git a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings index e303e66..c6dfb8a 100644 --- a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings +++ b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings @@ -16840,19 +16840,19 @@ "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 and pairing can't start. Sign in with the same Apple Account as your iPhone.": { + "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 and pairing can't start. 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 and pairing can't start. Sign in with the same Apple Account as your iPhone.!!]" } } + "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 受到限制,例如被“屏幕使用时间”或设备管理描述文件限制,因此无法联系设备。" } }, + "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.!!]" } } } }, @@ -16940,7 +16940,7 @@ "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "This device is no longer paired." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "这台设备已不再配对。" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "这台设备已不在配对列表中。" } }, "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This device is no longer paired.!!]" } } } }, @@ -16968,12 +16968,12 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!That didn't work. Try again.!!]" } } } }, - "This Mac stops trusting “%@” at once. Ask Key also tells the device, which removes this Mac the next time it connects.": { + "This Mac stops trusting “%@” at once. The device learns about it the next time it connects and removes this Mac. If it still shows this Mac later, remove this Mac on that device too.": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "This Mac stops trusting “%@” at once. Ask Key also tells the device, which removes this Mac the next time it connects." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "这台 Mac 会立即不再信任“%@”。请旨也会通知这台设备,它下次联网时会移除这台 Mac。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This Mac stops trusting “%@” at once. Ask Key also tells the device, which removes this Mac the next time it connects.!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "This Mac stops trusting “%@” at once. The device learns about it the next time it connects and removes this Mac. If it still shows this Mac later, remove this Mac on that device too." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "这台 Mac 将立即停止信任“%@”。对方下次联网时会得知并移除这台 Mac;如果之后它仍显示这台 Mac,请也在那台设备上移除这台 Mac。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This Mac stops trusting “%@” at once. The device learns about it the next time it connects and removes this Mac. If it still shows this Mac later, remove this Mac on that device too.!!]" } } } }, "Preparing…": { @@ -17108,7 +17108,7 @@ "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 上请旨配对页面显示的配对码。绝不要输入通知或消息中出现的代码。如果那个页面没有显示配对码,请取消。" } }, + "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.!!]" } } } }, @@ -17172,7 +17172,7 @@ "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 在配对完成后取消了这次配对,因此已重新移除这台设备。" } }, + "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.!!]" } } } }, @@ -17495,6 +17495,14 @@ "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/Views/Settings/DevicePairingSheet.swift b/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift index 4faa1b4..4227b9c 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift @@ -11,6 +11,7 @@ struct DevicePairingSheet: View { @FocusState private var codeFocused: Bool var body: some View { + let _ = AppLanguage.store.resolved let sheet = model.sheet ?? shown Group { if let sheet { diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesPresentation.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesPresentation.swift index fbedbfa..b73db8c 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicesPresentation.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesPresentation.swift @@ -44,7 +44,7 @@ enum DevicesPresentation { case .unknown, .available: return nil case .noAccount: - return appLocalized("iCloud is signed out, so paired devices can't be reached and pairing can't start. Sign in with the same Apple Account as your iPhone.") + 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: @@ -92,31 +92,29 @@ enum DevicesPresentation { appLocalizedFormat("%1$@ · Paired %2$@", platformName(row.platform), date(row.pairedAt, time: false)) } - /// The reachability and the last delivery check, as one status line. - static func rowStatus(_ row: PairedDeviceRow) -> Notice { - var parts: [String] - var isWarning = false + /// Whether the device was reached since pairing. + static func rowReachability(_ row: PairedDeviceRow) -> Notice { switch row.reachability { case .notReachedYet: - parts = [appLocalized("Not reached yet")] + return Notice(text: appLocalized("Not reached yet"), isWarning: false) case .reached(let date): - parts = [appLocalizedFormat("Last reached %@", self.date(date, time: true))] + return Notice(text: appLocalizedFormat("Last reached %@", self.date(date, time: true)), isWarning: false) case .unreachable: - // Why is said above the list; an older check result would only contradict it. 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: - parts.append(appLocalized("Delivery check sent")) - case .replied: - parts.append(appLocalized("Delivery check answered")) - case .noReply: - parts.append(appLocalized("Delivery check got no reply")) - isWarning = true - case nil: - break + 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 } - return Notice(text: parts.joined(separator: " · "), isWarning: isWarning) } static func issueMessage(_ issue: DeviceActionIssue) -> String { @@ -135,7 +133,7 @@ enum DevicesPresentation { } static func removalWarning(_ name: String) -> String { - appLocalizedFormat("This Mac stops trusting “%@” at once. Ask Key also tells the device, which removes this Mac the next time it connects.", name) + appLocalizedFormat("This Mac stops trusting “%@” at once. The device learns about it the next time it connects and removes this Mac. If it still shows this Mac later, remove this Mac on that device too.", name) } static func date(_ date: Date, time: Bool) -> String { diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift index eb33287..1f93b31 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift @@ -35,16 +35,24 @@ final class DevicesSettingsModel { var isSectionVisible: Bool { coordinator.availability != .notConfigured } var notice: DevicesPresentation.Notice { - DevicesPresentation.sectionNotice(availability: coordinator.availability, account: coordinator.account, + DevicesPresentation.sectionNotice(availability: availability, account: coordinator.account, deviceCount: coordinator.devices.count, storageFailureMessage: coordinator.storageFailureMessage) } var canPair: Bool { - DevicesPresentation.canPair(availability: coordinator.availability, account: coordinator.account, + 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 @@ -121,7 +129,7 @@ final class DevicesSettingsModel { codeEntry = "" qrCache = nil if coordinator.pairing?.isActive == true { await coordinator.cancelPairing() } - guard DevicesPresentation.canPair(availability: coordinator.availability, account: coordinator.account, + guard DevicesPresentation.canPair(availability: availability, account: coordinator.account, deviceCount: coordinator.devices.count, isPairing: false) else { await coordinator.cancelPairing() return @@ -193,6 +201,11 @@ final class DevicesSettingsModel { 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 diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift index 21fddc2..027a8c7 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift @@ -30,18 +30,21 @@ 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) - .disabled(model.confirmingReset) + .dimmedAndDisabled(model.confirmingReset) .accessibilityIdentifier("settings-reset-device-identity") } else { Button(appLocalized("Pair a Device…")) { Task { await model.pairDevice() } } .buttonStyle(.secondaryAction) - .disabled(!model.canPair) + .dimmedAndDisabled(!model.canPair) .accessibilityIdentifier("settings-pair-device") } } @@ -91,8 +94,10 @@ private struct DeviceRow: View { @FocusState private var nameFocused: Bool var body: some View { + let _ = AppLanguage.store.resolved let isRenaming = model.renaming == row.id - let status = DevicesPresentation.rowStatus(row) + 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)) @@ -120,10 +125,12 @@ private struct DeviceRow: View { Text(DevicesPresentation.rowSummary(row)) .font(Theme.Fonts.secondary) .foregroundStyle(Theme.textSecondary) - Text(status.text) - .font(Theme.Fonts.secondary) - .foregroundStyle(status.isWarning ? Theme.warning : Theme.textSecondary) - .fixedSize(horizontal: false, vertical: true) + ForEach([reachability, check].compactMap { $0 }, id: \.text) { line in + Text(line.text) + .font(Theme.Fonts.secondary) + .foregroundStyle(line.isWarning ? Theme.warning : Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } } Spacer(minLength: Theme.Spacing.md) if isRenaming { @@ -132,7 +139,7 @@ private struct DeviceRow: View { .keyboardShortcut(.cancelAction) Button(appLocalized("Save")) { Task { await model.saveRename() } } .buttonStyle(.secondaryAction) - .disabled(!model.canSaveRename) + .dimmedAndDisabled(!model.canSaveRename) .accessibilityIdentifier("devices-rename-save") } else { Button(appLocalized("Rename")) { model.beginRename(row) } @@ -140,11 +147,11 @@ private struct DeviceRow: View { .accessibilityIdentifier("devices-rename") Button(appLocalized("Test Delivery")) { Task { await model.testDelivery(row) } } .buttonStyle(.secondaryAction) - .disabled(!model.canTestDelivery) + .dimmedAndDisabled(!model.canTestDelivery) .accessibilityIdentifier("devices-test-delivery") Button(appLocalized("Remove"), role: .destructive) { model.requestRemoval(row) } .buttonStyle(.irreversibleAction) - .disabled(model.removalCandidate == row.id) + .dimmedAndDisabled(model.removalCandidate == row.id) .accessibilityIdentifier("devices-remove") } } @@ -217,3 +224,11 @@ private struct DevicesConfirmationRow: View { .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/PairingSheetPresentation.swift b/Sources/AskKeyAppKit/Views/Settings/PairingSheetPresentation.swift index be3fe65..07d39e3 100644 --- a/Sources/AskKeyAppKit/Views/Settings/PairingSheetPresentation.swift +++ b/Sources/AskKeyAppKit/Views/Settings/PairingSheetPresentation.swift @@ -64,16 +64,13 @@ struct PairingSheetPresentation: Equatable { case .codeAccepted(let acceptedPeer): stage = .codeEntry(accepted: true) peer = acceptedPeer - title = appLocalized("Enter the Pairing Code") - message = Self.codeInstruction + 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 if let failure = Self.authenticationFeedback(feedback) { - feedbackText = failure } else { - feedbackText = appLocalized("The code matches. Confirm to finish pairing; Ask Key asks for system authentication.") - feedbackIsWarning = false + feedbackText = Self.authenticationFeedback(feedback) } case .waitingForIPhoneConfirmation(let confirmingPeer): stage = .waitingForIPhoneConfirmation @@ -102,12 +99,6 @@ struct PairingSheetPresentation: Equatable { self.feedbackIsWarning = feedbackIsWarning } - /// 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 - } 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.") diff --git a/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift b/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift index 2a1fa15..ddd4495 100644 --- a/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift +++ b/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift @@ -112,6 +112,8 @@ final class DevicesSettingsModelTests: AskKeyAppTestCase { 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") @@ -209,7 +211,7 @@ final class DevicesSettingsModelTests: AskKeyAppTestCase { 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 also tells the device, which removes this Mac the next time it connects.") + "This Mac stops trusting “Synthetic Phone” at once. The device learns about it the next time it connects and removes this Mac. If it still shows this Mac later, remove this Mac on that device too.") model.cancelRemoval() await model.confirmRemoval() XCTAssertEqual(harness.coordinator.devices.count, 1, "a cancelled confirmation removes nothing") @@ -221,12 +223,26 @@ final class DevicesSettingsModelTests: AskKeyAppTestCase { XCTAssertEqual(harness.macSent(.unpairNotice).count, 1) } + 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.rowStatus(row).text.hasPrefix("Last reached ")) - XCTAssertTrue(DevicesPresentation.rowStatus(row).text.hasSuffix(" · Delivery check answered")) + 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} " @@ -239,14 +255,15 @@ final class DevicesSettingsModelTests: AskKeyAppTestCase { let checks = harness.macSent(.deliveryCheck).count await model.testDelivery(try XCTUnwrap(harness.coordinator.devices.first)) XCTAssertEqual(harness.macSent(.deliveryCheck).count, checks + 1) - XCTAssertTrue(DevicesPresentation.rowStatus(try XCTUnwrap(harness.coordinator.devices.first)).text - .hasSuffix(" · Delivery check sent")) + 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") - XCTAssertEqual(DevicesPresentation.rowStatus(try XCTUnwrap(harness.coordinator.devices.first)), - .init(text: "Can't be reached", isWarning: true)) + 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)) @@ -287,7 +304,7 @@ final class DevicesSettingsModelTests: AskKeyAppTestCase { 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 and pairing can't start. Sign in with the same Apple Account as your iPhone." + 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() @@ -295,7 +312,10 @@ final class DevicesSettingsModelTests: AskKeyAppTestCase { harness.backend.isSecureEnclaveAvailable = false harness.backend.accountState = .available(userRecordName: "_synthetic-account-1") - await model.pairDevice() + 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) @@ -360,3 +380,12 @@ final class DevicesSettingsModelTests: AskKeyAppTestCase { 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 + } +} From 44ee60a255f3123bc2314eebf9e185e14b097727 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Tue, 10 Nov 2026 16:58:18 +0800 Subject: [PATCH 03/21] Keep the pairing sheet up until the person leaves it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first CI screenshot run clicked Pair a Device… and no sheet appeared; afterwards the button was enabled again, so the session had been cancelled. The only path that does that without a click is the sheet binding's setter. It no longer cancels: every way out of the sheet goes through its buttons (Esc is Cancel) or the end of the management session. The QR code is drawn with Core Image's software renderer, which needs no GPU. The E2E device fixture records each pairing state the sheet reads, by case name only, in device-transitions.json, so a failed capture shows where the session went. --- .../Views/Settings/DevicesSettingsModel.swift | 4 +++- .../Settings/DevicesSettingsSection.swift | 11 ++++----- .../AskKeyTestSupport/E2EDeviceScenario.swift | 24 +++++++++++++++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift index 1f93b31..4f39d13 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift @@ -151,7 +151,9 @@ final class DevicesSettingsModel { filter.correctionLevel = "M" guard let output = filter.outputImage else { return nil } let scaled = output.transformed(by: CGAffineTransform(scaleX: 8, y: 8)) - guard let image = CIContext().createCGImage(scaled, from: scaled.extent) else { return nil } + // 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) } diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift index 027a8c7..b67ddcc 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift @@ -75,12 +75,11 @@ private struct DevicesSectionContent: View { } } .task { await model.sectionOpened() } - .sheet(isPresented: Binding( - get: { model.isSheetPresented }, - set: { presented in - if !presented { Task { await model.closeSheet() } } - } - )) { + // The session decides whether the sheet shows. Every way out goes + // through the sheet's buttons (Esc is Cancel) or the end of the + // management session, so a dismissal reported by SwiftUI alone never + // cancels a pairing. + .sheet(isPresented: Binding(get: { model.isSheetPresented }, set: { _ in })) { DevicePairingSheet(model: model) } } diff --git a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift index 51f8b6a..dc735d9 100644 --- a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift +++ b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift @@ -1,6 +1,7 @@ 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 @@ -17,6 +18,7 @@ enum E2EDeviceScenario { private static var backend: FakeDeviceServicesBackend? private static var phone: SimulatedPairingPhone? private static var task: Task? + private static var transitions: [String] = [] static func isRequested(_ scenario: String) -> Bool { scenario.hasPrefix(scenarioPrefix) @@ -33,6 +35,7 @@ enum E2EDeviceScenario { /// Runs each command once when its file appears, then removes the file /// and writes `device-.json` with the outcome. static func start(control: URL) { + recordPairingTransitions(control: control) task = Task { @MainActor in while !Task.isCancelled { for command in commands { @@ -61,6 +64,27 @@ enum E2EDeviceScenario { task = nil } + /// Writes `device-transitions.json` with each pairing state the sheet + /// reads, by case name only (the QR payload holds the pairing secret), + /// so a failed capture shows where the session went. + private static func recordPairingTransitions(control: URL) { + withObservationTracking { + _ = DeviceServicesHost.coordinator?.pairing + } onChange: { + Task { @MainActor in + if let coordinator = DeviceServicesHost.coordinator { + let state = coordinator.pairing.map { String(describing: $0).prefix { $0 != "(" } } ?? "none" + transitions.append("\(Date().timeIntervalSince1970) \(state) \(coordinator.availability) \(coordinator.account)") + 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) + } + } + recordPairingTransitions(control: control) + } + } + } + private static func run(_ command: String) async throws { guard let coordinator = DeviceServicesHost.coordinator, let phone, let backend else { throw E2EDeviceScenarioError.notInstalled From 09c10e4413bd93bef15f81c6945b26768f4e3ea7 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Tue, 10 Nov 2026 17:40:52 +0800 Subject: [PATCH 04/21] Address the second review round and diagnose the Devices captures - The sheet binding clears a session that has finished when SwiftUI reports the sheet dismissed; a live session still ends only through the sheet's buttons or the end of the management session. - On the QR step, Cancel and Pair Again share one row, so Cancel lines up with the other steps' buttons. - The removal confirmation says Ask Key tries to tell the device, which then removes this Mac when it next connects. - E2E: the second CI run again opened no sheet after the click, and no pairing state changed. The fixture now records the coordinator's state at launch and after every change the section reads; the test checks for the sheet first, clicks again or starts pairing through a fixture command when the click opened none, takes the captures, and fails at the end if the click was not enough. --- .../Resources/Localizable.xcstrings | 8 +-- .../Views/Settings/DevicePairingSheet.swift | 6 +- .../Views/Settings/DevicesPresentation.swift | 2 +- .../Settings/DevicesSettingsSection.swift | 15 +++-- .../Settings/PairingSheetPresentation.swift | 1 - .../DevicesSettingsModelTests.swift | 2 +- Tests/AskKeyE2ETests/ScreenshotE2ETests.swift | 15 +++++ .../AskKeyTestSupport/E2EDeviceScenario.swift | 55 +++++++++++++------ 8 files changed, 73 insertions(+), 31 deletions(-) diff --git a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings index c6dfb8a..4fcd2af 100644 --- a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings +++ b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings @@ -16968,12 +16968,12 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!That didn't work. Try again.!!]" } } } }, - "This Mac stops trusting “%@” at once. The device learns about it the next time it connects and removes this Mac. If it still shows this Mac later, remove this Mac on that device too.": { + "This Mac stops trusting “%@” at once. Ask Key tries to tell the device, which then removes this Mac the next time it connects. If it still lists this Mac later, remove this Mac on that device too.": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "This Mac stops trusting “%@” at once. The device learns about it the next time it connects and removes this Mac. If it still shows this Mac later, remove this Mac on that device too." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "这台 Mac 将立即停止信任“%@”。对方下次联网时会得知并移除这台 Mac;如果之后它仍显示这台 Mac,请也在那台设备上移除这台 Mac。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This Mac stops trusting “%@” at once. The device learns about it the next time it connects and removes this Mac. If it still shows this Mac later, remove this Mac on that device too.!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "This Mac stops trusting “%@” at once. Ask Key tries to tell the device, which then removes this Mac the next time it connects. If it still lists this Mac later, 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, which then removes this Mac the next time it connects. If it still lists this Mac later, remove this Mac on that device too.!!]" } } } }, "Preparing…": { diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift b/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift index 4227b9c..844008a 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift @@ -170,9 +170,11 @@ struct DevicePairingSheet: View { .disabled(!model.canConfirm) .accessibilityIdentifier("devices-pairing-confirm") case .qrCode(_, let expiresAt): - cancelButton TimelineView(.periodic(from: .now, by: 1)) { context in - if context.date >= expiresAt { pairAgainButton } + HStack(spacing: Theme.Spacing.sm) { + cancelButton + if context.date >= expiresAt { pairAgainButton } + } } case .preparing, .waitingForIPhone, .waitingForIPhoneConfirmation: cancelButton diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesPresentation.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesPresentation.swift index b73db8c..c43183e 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicesPresentation.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesPresentation.swift @@ -133,7 +133,7 @@ enum DevicesPresentation { } static func removalWarning(_ name: String) -> String { - appLocalizedFormat("This Mac stops trusting “%@” at once. The device learns about it the next time it connects and removes this Mac. If it still shows this Mac later, remove this Mac on that device too.", name) + appLocalizedFormat("This Mac stops trusting “%@” at once. Ask Key tries to tell the device, which then removes this Mac the next time it connects. If it still lists this Mac later, remove this Mac on that device too.", name) } static func date(_ date: Date, time: Bool) -> String { diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift index b67ddcc..d56e0eb 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift @@ -75,11 +75,16 @@ private struct DevicesSectionContent: View { } } .task { await model.sectionOpened() } - // The session decides whether the sheet shows. Every way out goes - // through the sheet's buttons (Esc is Cancel) or the end of the - // management session, so a dismissal reported by SwiftUI alone never - // cancels a pairing. - .sheet(isPresented: Binding(get: { model.isSheetPresented }, set: { _ in })) { + // 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) } } diff --git a/Sources/AskKeyAppKit/Views/Settings/PairingSheetPresentation.swift b/Sources/AskKeyAppKit/Views/Settings/PairingSheetPresentation.swift index 07d39e3..d9f1f64 100644 --- a/Sources/AskKeyAppKit/Views/Settings/PairingSheetPresentation.swift +++ b/Sources/AskKeyAppKit/Views/Settings/PairingSheetPresentation.swift @@ -99,7 +99,6 @@ struct PairingSheetPresentation: Equatable { 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.") } diff --git a/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift b/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift index ddd4495..164354d 100644 --- a/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift +++ b/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift @@ -211,7 +211,7 @@ final class DevicesSettingsModelTests: AskKeyAppTestCase { 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. The device learns about it the next time it connects and removes this Mac. If it still shows this Mac later, remove this Mac on that device too.") + "This Mac stops trusting “Synthetic Phone” at once. Ask Key tries to tell the device, which then removes this Mac the next time it connects. If it still lists this Mac later, remove this Mac on that device too.") model.cancelRemoval() await model.confirmRemoval() XCTAssertEqual(harness.coordinator.devices.count, 1, "a cancelled confirmation removes nothing") diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift index 707d302..5f7f15c 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift @@ -371,6 +371,20 @@ final class ScreenshotE2ETests: E2EBaseCase { click("unlock-management") click("sidebar-settings") openBackupSetting("settings-pair-device") + // Diagnose a click that opens no sheet instead of stopping there: + // click again, then start pairing through the fixture, and fail at + // the end so the captures and device-transitions.json still exist. + let sheet = app.windows["settings"].sheets.firstMatch + var startPath = "first click" + if !sheet.waitForExistence(timeout: 5) { + startPath = "second click" + click("settings-pair-device") + if !sheet.waitForExistence(timeout: 5) { + startPath = "fixture" + try fixture("start-pairing") + } + } + XCTAssertTrue(sheet.waitForExistence(timeout: 8), "No pairing sheet") XCTAssertTrue(app.descendants(matching: .any)["devices-pairing-qr"].waitForExistence(timeout: 8)) captureSheet(name(1, "devices-pairing-qr-code")) try fixture("phone-scan") @@ -390,6 +404,7 @@ final class ScreenshotE2ETests: E2EBaseCase { waitForText(language == "en" ? "iCloud is signed out" : "iCloud \u{5DF2}\u{9000}\u{51FA}\u{767B}\u{5F55}", containing: true) capture(name(4, "devices-icloud-signed-out")) + XCTAssertEqual(startPath, "first click", "Pair a Device… needed: \(startPath)") } /// Saves "Staging API" through the first-run editor, then unlocks diff --git a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift index dc735d9..69afa1e 100644 --- a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift +++ b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift @@ -13,7 +13,7 @@ import Observation 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"] + static let commands = ["start-pairing", "phone-scan", "phone-reveal", "phone-complete", "phone-poll", "account-signed-out"] private static var backend: FakeDeviceServicesBackend? private static var phone: SimulatedPairingPhone? @@ -64,24 +64,42 @@ enum E2EDeviceScenario { task = nil } - /// Writes `device-transitions.json` with each pairing state the sheet - /// reads, by case name only (the QR payload holds the pairing secret), - /// so a failed capture shows where the session went. - private static func recordPairingTransitions(control: URL) { + /// 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 { - _ = DeviceServicesHost.coordinator?.pairing + _ = (coordinator.pairing, coordinator.availability, coordinator.account, coordinator.hasIdentity, + coordinator.devices.count) } onChange: { - Task { @MainActor in - if let coordinator = DeviceServicesHost.coordinator { - let state = coordinator.pairing.map { String(describing: $0).prefix { $0 != "(" } } ?? "none" - transitions.append("\(Date().timeIntervalSince1970) \(state) \(coordinator.availability) \(coordinator.account)") - 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) - } - } - recordPairingTransitions(control: control) - } + 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) } } @@ -90,6 +108,9 @@ enum E2EDeviceScenario { throw E2EDeviceScenarioError.notInstalled } switch command { + case "start-pairing": + // Diagnostic only: what Pair a Device… does, for a run in which the click did not start it. + await coordinator.startPairing() case "phone-scan": guard case .showingQRCode(let text, _) = coordinator.pairing else { throw E2EDeviceScenarioError.noQRCode } try await phone.scan(text, name: phoneName) From b70d9995a2fb57ef85e68daab3de020dde3c67bf Mon Sep 17 00:00:00 2001 From: sudoHG Date: Tue, 10 Nov 2026 18:19:55 +0800 Subject: [PATCH 05/21] Shape the Devices section like the Backup section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third CI run settled where the captures fail: two synthesized clicks on Pair a Device… started no pairing, while the fixture calling the coordinator's startPairing() opened the sheet at once, so presentation works and the button's action does not run under the CI click. The section now follows the Backup section's layout, whose buttons the same run clicks successfully: the sheet hangs off the pairing row instead of the whole group, the button calls a method, and the section refreshes on appear instead of through a task. After the pairing, the runner's accessibility queries timed out after four 600-point scrolls. The test now scrolls only until the device row is hittable, and attaches the fixture's state record as soon as the sheet shows and after the delivery check, so it survives a later timeout. --- .../Settings/DevicesSettingsSection.swift | 32 +++++++++++-------- Tests/AskKeyE2ETests/ScreenshotE2ETests.swift | 17 ++++++++-- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift index d56e0eb..fd6f185 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift @@ -42,12 +42,24 @@ private struct DevicesSectionContent: View { .dimmedAndDisabled(model.confirmingReset) .accessibilityIdentifier("settings-reset-device-identity") } else { - Button(appLocalized("Pair a Device…")) { Task { await model.pairDevice() } } + 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.")) { @@ -74,19 +86,11 @@ private struct DevicesSectionContent: View { .accessibilityIdentifier("settings-devices-issue") } } - .task { await model.sectionOpened() } - // 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) - } + .onAppear { Task { await model.sectionOpened() } } + } + + private func pairDevice() { + Task { await model.pairDevice() } } } diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift index 5f7f15c..6d82768 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift @@ -385,6 +385,7 @@ final class ScreenshotE2ETests: E2EBaseCase { } } XCTAssertTrue(sheet.waitForExistence(timeout: 8), "No pairing sheet") + attachDeviceTransitions() XCTAssertTrue(app.descendants(matching: .any)["devices-pairing-qr"].waitForExistence(timeout: 8)) captureSheet(name(1, "devices-pairing-qr-code")) try fixture("phone-scan") @@ -396,8 +397,10 @@ final class ScreenshotE2ETests: E2EBaseCase { try fixture("phone-complete") click("devices-pairing-done") try fixture("phone-poll") - let settings = app.windows["settings"].scrollViews.containing(.button, identifier: "settings-pair-device").firstMatch - for _ in 0..<4 { settings.scroll(byDeltaX: 0, deltaY: -600) } + attachDeviceTransitions() + let row = app.buttons["devices-rename"].firstMatch + let settings = app.windows["settings"].scrollViews.containing(.button, identifier: "devices-rename").firstMatch + for _ in 0..<6 where !row.isHittable { settings.scroll(byDeltaX: 0, deltaY: -300) } waitForText(language == "en" ? "Last reached" : "\u{4E0A}\u{6B21}\u{8054}\u{7CFB}", containing: true) capture(name(0, "devices-list")) try fixture("account-signed-out") @@ -422,6 +425,16 @@ final class ScreenshotE2ETests: E2EBaseCase { XCTAssertTrue(app.buttons["settings-agent-access"].waitForExistence(timeout: 8)) } + /// The fixture's record of the device coordinator's states, attached at + /// once so it survives a later query timeout. + private func attachDeviceTransitions() { + guard let data = try? Data(contentsOf: runDirectory.appendingPathComponent("device-transitions.json")) else { return } + let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json") + attachment.name = "device-transitions.json" + 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 From 4f85b82ba0aa9b10a0cae4cc96e4fdc2d9da91a3 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Tue, 10 Nov 2026 19:04:49 +0800 Subject: [PATCH 06/21] Record the button state at each Pair a Device click in E2E MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnostics only, in Tests: before each click the fixture records what Pair a Device… checks (Secure Enclave, pairing, availability, account, identity, devices) and the runner records the button's enabled, hittable and frame values. A click that opens no sheet is followed by a click with the button scrolled higher, then the fixture start; the test fails at the end unless the first click opened the sheet. After the pairing the runner waits eight seconds so the state record shows what changes once the sheet has closed, and a teardown block terminates the App so a hung accessibility query cannot also hang the teardown. --- Tests/AskKeyE2ETests/ScreenshotE2ETests.swift | 63 +++++++++---------- .../AskKeyTestSupport/E2EDeviceScenario.swift | 10 ++- 2 files changed, 40 insertions(+), 33 deletions(-) diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift index 6d82768..8aa98d5 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift @@ -351,14 +351,11 @@ final class ScreenshotE2ETests: E2EBaseCase { 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 fixture plays the iPhone, - /// including the person typing its code, which never leaves the App. + /// 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 + /// fixture plays the iPhone, including the person typing its code, which never leaves the App. 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 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") @@ -367,25 +364,28 @@ final class ScreenshotE2ETests: E2EBaseCase { 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") - openBackupSetting("settings-pair-device") - // Diagnose a click that opens no sheet instead of stopping there: - // click again, then start pairing through the fixture, and fail at - // the end so the captures and device-transitions.json still exist. + // Diagnostics for a click that opened no sheet (#298): the button's state at each click, a second + // click with the button scrolled higher, then the fixture; the test fails at the end if needed. + let pair = app.buttons["settings-pair-device"].firstMatch + let page = app.windows["settings"].scrollViews.containing(.button, identifier: "settings-pair-device").firstMatch let sheet = app.windows["settings"].sheets.firstMatch - var startPath = "first click" - if !sheet.waitForExistence(timeout: 5) { - startPath = "second click" - click("settings-pair-device") - if !sheet.waitForExistence(timeout: 5) { - startPath = "fixture" - try fixture("start-pairing") - } + var clicks: [String] = [] + for extraScrolls in [0, 3] where !sheet.exists { + XCTAssertTrue(pair.waitForExistence(timeout: 8)) + for _ in 0..<6 where !pair.isHittable { page.scroll(byDeltaX: 0, deltaY: -300) } + for _ in 0..? private static var transitions: [String] = [] + private static var control: URL? static func isRequested(_ scenario: String) -> Bool { scenario.hasPrefix(scenarioPrefix) @@ -35,6 +36,7 @@ enum E2EDeviceScenario { /// 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 { @@ -108,6 +110,12 @@ enum E2EDeviceScenario { throw E2EDeviceScenarioError.notInstalled } switch command { + case "snapshot": + // What Pair a Device… checks before it starts, at the moment the runner clicks. + transitions.append("\(Date().timeIntervalSince1970) snapshot before a click: secureEnclave=\(backend.isSecureEnclaveAvailable) " + + "pairing=\(coordinator.pairing.map(describe) ?? "none") availability=\(coordinator.availability) " + + "account=\(coordinator.account) identity=\(coordinator.hasIdentity) devices=\(coordinator.devices.count)") + if let control { writeTransitions(control: control) } case "start-pairing": // Diagnostic only: what Pair a Device… does, for a run in which the click did not start it. await coordinator.startPairing() From 663d830177d6cb9d3711f54fba2e46fa2d4dd3fc Mon Sep 17 00:00:00 2001 From: sudoHG Date: Tue, 10 Nov 2026 22:17:42 +0800 Subject: [PATCH 07/21] Record the App's mouse events and accessibility tree in the Devices E2E --- .../ScreenshotE2ETests+Devices.swift | 117 ++++++++++++++++++ Tests/AskKeyE2ETests/ScreenshotE2ETests.swift | 84 +------------ .../E2EDeviceDiagnostics.swift | 107 ++++++++++++++++ .../AskKeyTestSupport/E2EDeviceScenario.swift | 19 ++- 4 files changed, 246 insertions(+), 81 deletions(-) create mode 100644 Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift create mode 100644 Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift new file mode 100644 index 0000000..699cadb --- /dev/null +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift @@ -0,0 +1,117 @@ +import XCTest + +/// Settings → Devices screenshots (#288): 30–34 in English and 35–39 in Chinese. +extension ScreenshotE2ETests { + func testDevicesScreensInEnglish() throws { + try captureDevicesScreens(language: "en", tag: "en", firstNumber: 30) + } + + func testDevicesScreensInChinese() throws { + try captureDevicesScreens(language: "zh-Hans", tag: "zh", firstNumber: 35) + } + + /// The list with one paired iPhone, the pairing sheet's QR code, waiting and code entry steps, + /// and iCloud signed out, on the E2E App's fake device services. The fixture plays the iPhone, + /// including the person typing its code, which never leaves the App. + 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? FileManager.default.removeItem(at: runDirectory.appendingPathComponent("device-\(command).json")) + try sendFixtureCommand(command) + let report = try waitForEvidence("device-\(command).json") + XCTAssertEqual(report["ok"] as? Bool, true, "\(command): \(report)") + } + var notes: [String] = [] + var walks = 0 + func walkAccessibility() throws { + try fixture("ax-walk") + walks += 1 + attachDeviceFile("device-ax-walk-\(walks).txt") + } + func timed(_ label: String, _ query: () -> Bool) { + let began = Date() + let found = query() + notes.append("\(label): found=\(found) in \(String(format: "%.1f", Date().timeIntervalSince(began))) s") + } + 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 pair = app.buttons["settings-pair-device"].firstMatch + let page = app.windows["settings"].scrollViews.containing(.button, identifier: "settings-pair-device").firstMatch + let sheet = app.windows["settings"].sheets.firstMatch + XCTAssertTrue(pair.waitForExistence(timeout: 8)) + for _ in 0..<6 where !pair.isHittable { page.scroll(byDeltaX: 0, deltaY: -300) } + try walkAccessibility() + // #298 diagnostics: ways of clicking Pair a Device…, while the fixture records the mouse + // events the App receives; the fixture starts the session if none opened the sheet. + let clicks: [(String, () -> Void)] = [ + ("click", { pair.click() }), + ("coordinate click", { pair.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click() }), + ("hover, then press", { pair.hover(); pair.press(forDuration: 0.2) }), + ("click near the leading edge", { pair.coordinate(withNormalizedOffset: CGVector(dx: 0.15, dy: 0.5)).click() }) + ] + for (label, perform) in clicks where !sheet.exists { + try fixture("snapshot") + notes.append("\(label): enabled=\(pair.isEnabled) hittable=\(pair.isHittable) frame=\(pair.frame)") + perform() + notes.append(sheet.waitForExistence(timeout: 3) ? "sheet" : "no sheet") + } + if !sheet.exists { notes.append("fixture start"); try fixture("start-pairing") } + XCTAssertTrue(sheet.waitForExistence(timeout: 8), "No pairing sheet") + attachDeviceDiagnostics(notes) + 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")) + // #298 diagnostics: the English run drops the code field's focus before the field goes + // away; the Chinese run keeps it until after the sheet has closed. + if language == "en" { try fixture("resign-focus") } + try fixture("phone-complete") + click("devices-pairing-done") + try fixture("phone-poll") + try walkAccessibility() + let lastReached = language == "en" ? "Last reached" : "\u{4E0A}\u{6B21}\u{8054}\u{7CFB}" + let reached = NSPredicate(format: "label CONTAINS %@ OR value CONTAINS %@", lastReached, lastReached) + timed("text query after Done") { app.descendants(matching: .any).matching(reached).firstMatch.waitForExistence(timeout: 2) } + if language != "en" { + try fixture("resign-focus") + try walkAccessibility() + timed("text query after dropping focus") { app.descendants(matching: .any).matching(reached).firstMatch.waitForExistence(timeout: 2) } + } + attachDeviceDiagnostics(notes) + let row = app.buttons["devices-rename"].firstMatch + for _ in 0..<6 where !row.isHittable { page.scroll(byDeltaX: 0, deltaY: -300) } + waitForText(lastReached, containing: true) + capture(name(0, "devices-list")) + try fixture("account-signed-out") + waitForText(language == "en" ? "iCloud is signed out" : "iCloud \u{5DF2}\u{9000}\u{51FA}\u{767B}\u{5F55}", containing: true) + capture(name(4, "devices-icloud-signed-out")) + XCTAssertEqual(notes.first { $0.hasSuffix("sheet") || $0 == "fixture start" }, "sheet", "Pair a Device…: \(notes)") + } + + /// The fixture's record (the coordinator's states, the App's mouse events and accessibility + /// walks) and the notes so far, attached at once so they survive a later query timeout. + private func attachDeviceDiagnostics(_ notes: [String]) { + let record = (try? String(contentsOf: runDirectory.appendingPathComponent("device-transitions.json"), encoding: .utf8)) ?? "none" + let attachment = XCTAttachment(string: notes.joined(separator: "\n") + "\n\n" + record) + attachment.name = "device-diagnostics" + attachment.lifetime = .keepAlways + add(attachment) + } + + private func attachDeviceFile(_ file: String) { + let text = (try? String(contentsOf: runDirectory.appendingPathComponent(file), encoding: .utf8)) ?? "missing \(file)" + let attachment = XCTAttachment(string: text) + attachment.name = file + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift index 8aa98d5..759024c 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift @@ -343,72 +343,6 @@ 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 - /// fixture plays the iPhone, including the person typing its code, which never leaves the App. - 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)") - } - 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") - // Diagnostics for a click that opened no sheet (#298): the button's state at each click, a second - // click with the button scrolled higher, then the fixture; the test fails at the end if needed. - let pair = app.buttons["settings-pair-device"].firstMatch - let page = app.windows["settings"].scrollViews.containing(.button, identifier: "settings-pair-device").firstMatch - let sheet = app.windows["settings"].sheets.firstMatch - var clicks: [String] = [] - for extraScrolls in [0, 3] where !sheet.exists { - XCTAssertTrue(pair.waitForExistence(timeout: 8)) - for _ in 0..<6 where !pair.isHittable { page.scroll(byDeltaX: 0, deltaY: -300) } - for _ in 0.. Void) { + guard monitor == nil else { return } + monitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .leftMouseUp]) { event in + report(describe(event)) + return event + } + } + + /// Walks every window's accessibility tree in the App's process, writes the listing to + /// `device-ax-walk-.txt` and returns a summary: the element count, any cycle, the slowest + /// elements, the first responder, and the AppKit views under the Pair a Device… button. + static func walkAccessibility(control: URL) -> String { + walks += 1 + var lines: [String] = [], slow: [String] = [] + var seen = Set() + var count = 0, cycles = 0, widest = 0 + var pairFrame: NSRect? + let start = Date() + func walk(_ object: Any, depth: Int) { + guard count < 30_000, Date().timeIntervalSince(start) < 10 else { return } + let node = object as AnyObject + let pad = String(repeating: " ", count: depth) + guard seen.insert(ObjectIdentifier(node)).inserted else { + cycles += 1 + lines.append(pad + "CYCLE \(type(of: node))") + return + } + count += 1 + guard let element = node as? NSObject else { return lines.append(pad + "not an object: \(type(of: node))") } + let began = Date() + let role = element.accessibilityAttributeValue(.role) as? String ?? "-" + let identifier = element.accessibilityAttributeValue(.identifier) as? String ?? "" + let label = element.accessibilityAttributeValue(NSAccessibility.Attribute(rawValue: "AXDescription")) as? String + ?? element.accessibilityAttributeValue(.title) as? String ?? "" + let value = element.accessibilityAttributeValue(.value).map { String(describing: $0) } ?? "" + let frame = (element as? NSAccessibilityElementProtocol)?.accessibilityFrame() ?? .zero + let focused = element.accessibilityAttributeValue(.focused) as? Bool ?? false + let children = element.accessibilityAttributeValue(.children) as? [Any] ?? [] + let elapsed = Date().timeIntervalSince(began) + widest = max(widest, children.count) + if identifier == "settings-pair-device" { pairFrame = frame } + let line = "\(pad)\(type(of: node)) role=\(role) id=\(identifier) label=\(label.prefix(60)) value=\(value.prefix(60)) " + + "frame=\(frame) focused=\(focused) children=\(children.count) took=\(String(format: "%.3f", elapsed))" + lines.append(line) + if elapsed > 0.05 { slow.append(line) } + for child in children { walk(child, depth: depth + 1) } + } + for window in NSApp.windows { walk(window, depth: 0) } + let summary = "accessibility walk \(walks): elements=\(count) cycles=\(cycles) widest=\(widest) slow=\(slow.count) " + + "took=\(String(format: "%.2f", Date().timeIntervalSince(start))) key=\(NSApp.keyWindow.map(name) ?? "none") " + + "firstResponder=\(NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "none") " + + "windows=\(NSApp.windows.map { "\(name($0)) visible=\($0.isVisible) sheet=\($0.attachedSheet != nil)" }) " + + "pair=\(pairFrame.map(viewsUnder) ?? "absent")" + let listing = ([summary] + slow.map { "SLOW " + $0 } + lines).joined(separator: "\n") + try? listing.write(to: control.appendingPathComponent("device-ax-walk-\(walks).txt"), atomically: true, encoding: .utf8) + return summary + } + + /// The AppKit views under the centre of a screen rectangle, in each visible window containing it. + private static func viewsUnder(_ screenFrame: NSRect) -> String { + let point = NSPoint(x: screenFrame.midX, y: screenFrame.midY) + let hits = NSApp.windows.filter { $0.isVisible && $0.frame.contains(point) }.map { window in + "\(name(window)) frame=\(screenFrame) hit=\(chain(window, at: window.convertPoint(fromScreen: point)))" + } + return hits.isEmpty ? "no window at \(point)" : hits.joined(separator: "; ") + } + + private static func describe(_ event: NSEvent) -> String { + let window = event.window + let down = event.type == .leftMouseDown + var text = "\(down ? "mouse down" : "mouse up") window=\(window.map(name) ?? "none") key=\(window?.isKeyWindow ?? false) " + + "active=\(NSApp.isActive) point=\(event.locationInWindow) screen=\(NSEvent.mouseLocation) " + + "hit=\(window.map { chain($0, at: event.locationInWindow) } ?? "none") " + + "firstResponder=\(window?.firstResponder.map { String(describing: type(of: $0)) } ?? "none") " + + "sheet=\(window?.attachedSheet != nil) clicks=\(event.clickCount)" + if down { + text += " visible=\(NSApp.windows.filter(\.isVisible).map { "\(name($0)) level=\($0.level.rawValue) frame=\($0.frame)" })" + } + return text + } + + private static func chain(_ window: NSWindow, at point: NSPoint) -> String { + var views: [String] = [] + var view = window.contentView?.superview?.hitTest(point) + while let current = view, views.count < 8 { + views.append(String(describing: type(of: current))) + view = current.superview + } + return "\(views)" + } + + private static func name(_ window: NSWindow) -> String { + "\(type(of: window))[\(window.identifier?.rawValue ?? "-")]" + } +} diff --git a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift index 7f3a75d..ca69048 100644 --- a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift +++ b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift @@ -1,3 +1,4 @@ +import AppKit import AskKeyAppKit import AskKeyDevices import Foundation @@ -13,7 +14,7 @@ import Observation enum E2EDeviceScenario { static let scenarioPrefix = "screenshots-devices" static let phoneName = "Synthetic iPhone" - static let commands = ["snapshot", "start-pairing", "phone-scan", "phone-reveal", "phone-complete", "phone-poll", "account-signed-out"] + static let commands = ["snapshot", "start-pairing", "phone-scan", "phone-reveal", "phone-complete", "phone-poll", "account-signed-out", "ax-walk", "resign-focus"] private static var backend: FakeDeviceServicesBackend? private static var phone: SimulatedPairingPhone? @@ -38,6 +39,10 @@ enum E2EDeviceScenario { static func start(control: URL) { self.control = control recordPairingTransitions(control: control) + E2EDeviceDiagnostics.recordMouseEvents { event in + transitions.append("\(Date().timeIntervalSince1970) \(event)") + writeTransitions(control: control) + } task = Task { @MainActor in while !Task.isCancelled { for command in commands { @@ -146,6 +151,18 @@ enum E2EDeviceScenario { if case .reached = coordinator.devices.first?.reachability { return true } return false } + case "ax-walk": + // Diagnostic only (#298): the App's own accessibility tree. + guard let control else { throw E2EDeviceScenarioError.notInstalled } + transitions.append("\(Date().timeIntervalSince1970) \(E2EDeviceDiagnostics.walkAccessibility(control: control))") + writeTransitions(control: control) + case "resign-focus": + // Diagnostic only (#298): the key window drops its first responder. + let window = NSApp.keyWindow + transitions.append("\(Date().timeIntervalSince1970) resign focus: key=\(window.map { String(describing: type(of: $0)) } ?? "none") " + + "firstResponder=\(window?.firstResponder.map { String(describing: type(of: $0)) } ?? "none") " + + "resigned=\(window?.makeFirstResponder(nil) ?? false)") + if let control { writeTransitions(control: control) } case "account-signed-out": backend.accountState = .noAccount backend.signalAccountChange() From 747ef730c4b7ad821eb8580f8e9eeadc76b9ef34 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Tue, 10 Nov 2026 22:50:22 +0800 Subject: [PATCH 08/21] Compare another Settings button, a posted click and window-scoped queries in the Devices E2E --- .../ScreenshotE2ETests+Devices.swift | 68 +++++++++++++------ .../E2EDeviceDiagnostics.swift | 41 +++++++++-- .../AskKeyTestSupport/E2EDeviceScenario.swift | 15 +++- 3 files changed, 97 insertions(+), 27 deletions(-) diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift index 699cadb..814262c 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift @@ -46,19 +46,39 @@ extension ScreenshotE2ETests { XCTAssertTrue(pair.waitForExistence(timeout: 8)) for _ in 0..<6 where !pair.isHittable { page.scroll(byDeltaX: 0, deltaY: -300) } try walkAccessibility() - // #298 diagnostics: ways of clicking Pair a Device…, while the fixture records the mouse - // events the App receives; the fixture starts the session if none opened the sheet. + // #298 diagnostics: whether another button in the same page opens its sheet in this + // scenario, then XCTest's clicks on Pair a Device… and a click the App posts to itself, + // while the fixture records the mouse events the App receives. The fixture starts the + // session if none of them opened the sheet. + let export = app.buttons["settings-export-backup"].firstMatch + notes.append("export: hittable=\(export.isHittable) frame=\(export.frame)") + export.click() + if sheet.waitForExistence(timeout: 5) { + notes.append("export sheet") + click("backup-export-cancel") + notes.append("export sheet closed=\(sheet.waitForNonExistence(timeout: 5))") + } else { + notes.append("export: no sheet, alerts=\(app.alerts.count)") + if app.alerts.firstMatch.exists { app.alerts.firstMatch.buttons.firstMatch.click() } + } let clicks: [(String, () -> Void)] = [ ("click", { pair.click() }), - ("coordinate click", { pair.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click() }), - ("hover, then press", { pair.hover(); pair.press(forDuration: 0.2) }), - ("click near the leading edge", { pair.coordinate(withNormalizedOffset: CGVector(dx: 0.15, dy: 0.5)).click() }) + ("hover, then press", { pair.hover(); pair.press(forDuration: 0.2) }) ] + var clickOpened: [Bool] = [] for (label, perform) in clicks where !sheet.exists { try fixture("snapshot") notes.append("\(label): enabled=\(pair.isEnabled) hittable=\(pair.isHittable) frame=\(pair.frame)") perform() - notes.append(sheet.waitForExistence(timeout: 3) ? "sheet" : "no sheet") + clickOpened.append(sheet.waitForExistence(timeout: 3)) + notes.append(clickOpened.last == true ? "sheet" : "no sheet") + } + if !sheet.exists { + let centre = CGPoint(x: pair.frame.midX, y: pair.frame.midY) + try? FileManager.default.removeItem(at: runDirectory.appendingPathComponent("device-inject-click.json")) + try "\(centre.x) \(centre.y)\n".write(to: runDirectory.appendingPathComponent("command-inject-click.txt"), atomically: true, encoding: .utf8) + _ = try waitForEvidence("device-inject-click.json") + notes.append("posted click at \(centre): " + (sheet.waitForExistence(timeout: 3) ? "sheet" : "no sheet")) } if !sheet.exists { notes.append("fixture start"); try fixture("start-pairing") } XCTAssertTrue(sheet.waitForExistence(timeout: 8), "No pairing sheet") @@ -71,30 +91,30 @@ extension ScreenshotE2ETests { try fixture("phone-reveal") XCTAssertTrue(app.textFields["devices-pairing-code"].waitForExistence(timeout: 8)) captureSheet(name(3, "devices-pairing-code-entry")) - // #298 diagnostics: the English run drops the code field's focus before the field goes - // away; the Chinese run keeps it until after the sheet has closed. - if language == "en" { try fixture("resign-focus") } try fixture("phone-complete") click("devices-pairing-done") try fixture("phone-poll") try walkAccessibility() + // #298 diagnostics: the same text query in the Settings window, then in the whole App. let lastReached = language == "en" ? "Last reached" : "\u{4E0A}\u{6B21}\u{8054}\u{7CFB}" - let reached = NSPredicate(format: "label CONTAINS %@ OR value CONTAINS %@", lastReached, lastReached) - timed("text query after Done") { app.descendants(matching: .any).matching(reached).firstMatch.waitForExistence(timeout: 2) } - if language != "en" { - try fixture("resign-focus") - try walkAccessibility() - timed("text query after dropping focus") { app.descendants(matching: .any).matching(reached).firstMatch.waitForExistence(timeout: 2) } + let settings = app.windows["settings"].firstMatch + func textQuery(_ text: String, in root: XCUIElement) -> XCUIElement { + root.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS %@ OR value CONTAINS %@", text, text)).firstMatch } + timed("Settings window text query after Done") { textQuery(lastReached, in: settings).waitForExistence(timeout: 8) } attachDeviceDiagnostics(notes) let row = app.buttons["devices-rename"].firstMatch for _ in 0..<6 where !row.isHittable { page.scroll(byDeltaX: 0, deltaY: -300) } - waitForText(lastReached, containing: true) - capture(name(0, "devices-list")) + XCTAssertTrue(textQuery(lastReached, in: settings).waitForExistence(timeout: 8)) + captureSettingsWindow(name(0, "devices-list")) try fixture("account-signed-out") - waitForText(language == "en" ? "iCloud is signed out" : "iCloud \u{5DF2}\u{9000}\u{51FA}\u{767B}\u{5F55}", containing: true) - capture(name(4, "devices-icloud-signed-out")) - XCTAssertEqual(notes.first { $0.hasSuffix("sheet") || $0 == "fixture start" }, "sheet", "Pair a Device…: \(notes)") + let signedOut = language == "en" ? "iCloud is signed out" : "iCloud \u{5DF2}\u{9000}\u{51FA}\u{767B}\u{5F55}" + XCTAssertTrue(textQuery(signedOut, in: settings).waitForExistence(timeout: 8)) + captureSettingsWindow(name(4, "devices-icloud-signed-out")) + // Last, as it may time out: the same text query over the whole App. + timed("App text query after pairing") { textQuery(signedOut, in: app).waitForExistence(timeout: 2) } + attachDeviceDiagnostics(notes) + XCTAssertEqual(clickOpened.first, true, "Pair a Device…: \(notes)") } /// The fixture's record (the coordinator's states, the App's mouse events and accessibility @@ -107,6 +127,14 @@ extension ScreenshotE2ETests { add(attachment) } + /// The Settings window alone: the App-wide window list can include system text-input windows. + private func captureSettingsWindow(_ name: String) { + let attachment = XCTAttachment(screenshot: app.windows["settings"].firstMatch.screenshot()) + attachment.name = name + ".png" + attachment.lifetime = .keepAlways + add(attachment) + } + private func attachDeviceFile(_ file: String) { let text = (try? String(contentsOf: runDirectory.appendingPathComponent(file), encoding: .utf8)) ?? "missing \(file)" let attachment = XCTAttachment(string: text) diff --git a/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift b/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift index fb0d981..63eaf29 100644 --- a/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift +++ b/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift @@ -18,9 +18,10 @@ enum E2EDeviceDiagnostics { } } - /// Walks every window's accessibility tree in the App's process, writes the listing to - /// `device-ax-walk-.txt` and returns a summary: the element count, any cycle, the slowest - /// elements, the first responder, and the AppKit views under the Pair a Device… button. + /// Walks every window's accessibility tree in the App's process and writes the listing, + /// with each visible window's AppKit views and their frames, to `device-ax-walk-.txt`. + /// Returns a summary: the element count, any cycle, the slowest elements, the first + /// responder, and the AppKit views under the Pair a Device… button. static func walkAccessibility(control: URL) -> String { walks += 1 var lines: [String] = [], slow: [String] = [] @@ -63,11 +64,43 @@ enum E2EDeviceDiagnostics { + "firstResponder=\(NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "none") " + "windows=\(NSApp.windows.map { "\(name($0)) visible=\($0.isVisible) sheet=\($0.attachedSheet != nil)" }) " + "pair=\(pairFrame.map(viewsUnder) ?? "absent")" - let listing = ([summary] + slow.map { "SLOW " + $0 } + lines).joined(separator: "\n") + var views: [String] = [] + func dump(_ view: NSView, depth: Int) { + guard views.count < 2_000 else { return } + let frame = view.window.map { _ in view.convert(view.bounds, to: nil) } ?? view.frame + views.append("\(String(repeating: " ", count: depth))\(type(of: view)) window-frame=\(frame) hidden=\(view.isHidden) " + + "alpha=\(view.alphaValue) layer=\(view.wantsLayer)") + for subview in view.subviews { dump(subview, depth: depth + 1) } + } + for window in NSApp.windows where window.isVisible { + views.append("VIEWS \(name(window)) frame=\(window.frame)") + if let frameView = window.contentView?.superview { dump(frameView, depth: 1) } + } + let listing = ([summary] + slow.map { "SLOW " + $0 } + views + lines).joined(separator: "\n") try? listing.write(to: control.appendingPathComponent("device-ax-walk-\(walks).txt"), atomically: true, encoding: .utf8) return summary } + /// Posts a left mouse down and up to the App's own event queue at a point in XCTest's screen + /// coordinates (top-left origin), as AppKit would queue a hardware click. Both are queued + /// together, so a view that tracks the mouse after the down finds the up. + static func postClick(atRunnerPoint point: NSPoint) async -> String { + guard let screen = NSScreen.screens.first else { return "no screen" } + let screenPoint = NSPoint(x: point.x, y: screen.frame.maxY - point.y) + guard let window = NSApp.windows.first(where: { $0.isVisible && $0.level == .normal && $0.frame.contains(screenPoint) }) else { + return "no window at \(screenPoint)" + } + let location = window.convertPoint(fromScreen: screenPoint) + let now = ProcessInfo.processInfo.systemUptime + let events = [(NSEvent.EventType.leftMouseDown, now, Float(1)), (.leftMouseUp, now + 0.1, Float(0))].compactMap { type, time, pressure in + NSEvent.mouseEvent(with: type, location: location, modifierFlags: [], timestamp: time, windowNumber: window.windowNumber, + context: nil, eventNumber: 0, clickCount: 1, pressure: pressure) + } + events.forEach { NSApp.postEvent($0, atStart: false) } + try? await Task.sleep(for: .milliseconds(500)) + return "posted \(events.count) events at \(location) in \(name(window))" + } + /// The AppKit views under the centre of a screen rectangle, in each visible window containing it. private static func viewsUnder(_ screenFrame: NSRect) -> String { let point = NSPoint(x: screenFrame.midX, y: screenFrame.midY) diff --git a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift index ca69048..034db9c 100644 --- a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift +++ b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift @@ -14,7 +14,7 @@ import Observation enum E2EDeviceScenario { static let scenarioPrefix = "screenshots-devices" static let phoneName = "Synthetic iPhone" - static let commands = ["snapshot", "start-pairing", "phone-scan", "phone-reveal", "phone-complete", "phone-poll", "account-signed-out", "ax-walk", "resign-focus"] + static let commands = ["snapshot", "start-pairing", "phone-scan", "phone-reveal", "phone-complete", "phone-poll", "account-signed-out", "ax-walk", "resign-focus", "inject-click"] private static var backend: FakeDeviceServicesBackend? private static var phone: SimulatedPairingPhone? @@ -48,10 +48,11 @@ enum E2EDeviceScenario { for command in commands { let file = control.appendingPathComponent("command-\(command).txt") guard FileManager.default.fileExists(atPath: file.path) else { continue } + let argument = (try? String(contentsOf: file, encoding: .utf8)) ?? "" try? FileManager.default.removeItem(at: file) var report: [String: Any] = ["command": command] do { - try await run(command) + try await run(command, argument: argument) report["ok"] = true } catch { report["ok"] = false @@ -110,7 +111,7 @@ enum E2EDeviceScenario { } } - private static func run(_ command: String) async throws { + private static func run(_ command: String, argument: String) async throws { guard let coordinator = DeviceServicesHost.coordinator, let phone, let backend else { throw E2EDeviceScenarioError.notInstalled } @@ -163,6 +164,14 @@ enum E2EDeviceScenario { + "firstResponder=\(window?.firstResponder.map { String(describing: type(of: $0)) } ?? "none") " + "resigned=\(window?.makeFirstResponder(nil) ?? false)") if let control { writeTransitions(control: control) } + case "inject-click": + // Diagnostic only (#298): a click the App posts to itself at a point in the runner's + // screen coordinates, beside XCTest's synthesized one. + let values = argument.split(whereSeparator: \.isWhitespace).compactMap { Double($0) } + guard values.count == 2 else { throw E2EDeviceScenarioError.unknownCommand } + let outcome = await E2EDeviceDiagnostics.postClick(atRunnerPoint: NSPoint(x: values[0], y: values[1])) + transitions.append("\(Date().timeIntervalSince1970) posted click: \(outcome)") + if let control { writeTransitions(control: control) } case "account-signed-out": backend.accountState = .noAccount backend.signalAccountChange() From 7015b42a7f8da9ff0ec0a1276845221515573dd1 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Tue, 10 Nov 2026 23:18:42 +0800 Subject: [PATCH 09/21] Click replicas of the Devices row and press its button through accessibility in the E2E --- .../ScreenshotE2ETests+Devices.swift | 25 ++- .../E2EDeviceButtonVariants.swift | 168 ++++++++++++++++++ .../E2EDeviceDiagnostics.swift | 17 ++ .../AskKeyTestSupport/E2EDeviceScenario.swift | 17 +- 4 files changed, 218 insertions(+), 9 deletions(-) create mode 100644 Tests/AskKeyTestSupport/E2EDeviceButtonVariants.swift diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift index 814262c..80ed054 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift @@ -15,9 +15,9 @@ extension ScreenshotE2ETests { /// including the person typing its code, which never leaves the App. 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 { + func fixture(_ command: String, _ argument: String = "requested") throws { try? FileManager.default.removeItem(at: runDirectory.appendingPathComponent("device-\(command).json")) - try sendFixtureCommand(command) + try "\(argument)\n".write(to: runDirectory.appendingPathComponent("command-\(command).txt"), atomically: true, encoding: .utf8) let report = try waitForEvidence("device-\(command).json") XCTAssertEqual(report["ok"] as? Bool, true, "\(command): \(report)") } @@ -38,6 +38,17 @@ extension ScreenshotE2ETests { app.launch() addTeardownBlock { [app] in app?.terminate() } // a hung query must not also hang the teardown moveManagementAboveDock() + // #298 diagnostics: replicas of the Devices row, each without one of its differences. + try fixture("show-variants") + let variants = app.windows["devices-variants"].firstMatch + XCTAssertTrue(variants.waitForExistence(timeout: 8)) + for identifier in ["full", "no-sheet", "item-sheet", "no-dimming", "disabled-only", "no-branch", "no-outer", "backup"] { + let button = variants.buttons["variant-\(identifier)"].firstMatch + notes.append("variant \(identifier): hittable=\(button.isHittable)") + if button.isHittable { button.click() } + } + Thread.sleep(forTimeInterval: 1) + try fixture("hide-variants") click("unlock-management") click("sidebar-settings") let pair = app.buttons["settings-pair-device"].firstMatch @@ -73,12 +84,10 @@ extension ScreenshotE2ETests { clickOpened.append(sheet.waitForExistence(timeout: 3)) notes.append(clickOpened.last == true ? "sheet" : "no sheet") } - if !sheet.exists { - let centre = CGPoint(x: pair.frame.midX, y: pair.frame.midY) - try? FileManager.default.removeItem(at: runDirectory.appendingPathComponent("device-inject-click.json")) - try "\(centre.x) \(centre.y)\n".write(to: runDirectory.appendingPathComponent("command-inject-click.txt"), atomically: true, encoding: .utf8) - _ = try waitForEvidence("device-inject-click.json") - notes.append("posted click at \(centre): " + (sheet.waitForExistence(timeout: 3) ? "sheet" : "no sheet")) + let centre = "\(pair.frame.midX) \(pair.frame.midY)" + for command in ["inject-click", "ax-press"] where !sheet.exists { + try fixture(command, centre) + notes.append("\(command) at \(centre): " + (sheet.waitForExistence(timeout: 3) ? "sheet" : "no sheet")) } if !sheet.exists { notes.append("fixture start"); try fixture("start-pairing") } XCTAssertTrue(sheet.waitForExistence(timeout: 8), "No pairing sheet") diff --git a/Tests/AskKeyTestSupport/E2EDeviceButtonVariants.swift b/Tests/AskKeyTestSupport/E2EDeviceButtonVariants.swift new file mode 100644 index 0000000..ea925fc --- /dev/null +++ b/Tests/AskKeyTestSupport/E2EDeviceButtonVariants.swift @@ -0,0 +1,168 @@ +import AppKit +import Observation +import SwiftUI + +/// Diagnostics for #298, in the E2E App only: on macOS 26, clicks on Pair a Device… do not reach +/// its action, while Export Backup… in the same page works. This window shows replicas of the +/// Devices row, each without one of its differences from the Backup row, so a run shows which +/// difference keeps a click from the button. +@MainActor +enum E2EDeviceButtonVariants { + private static var window: NSWindow? + + /// The replicas: each identifier's button reports `variant fired` when its action runs. + static let identifiers = ["full", "no-sheet", "item-sheet", "no-dimming", "disabled-only", "no-branch", "no-outer", "backup"] + + static func show(_ report: @escaping @MainActor (String) -> Void) { + let content = ScrollView { + VStack(alignment: .leading, spacing: 12) { + ForEach(identifiers, id: \.self) { identifier in + VariantSection(identifier: identifier, report: report) + } + } + .padding(16) + } + let window = NSWindow(contentRect: NSRect(x: 60, y: 40, width: 560, height: 700), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + window.identifier = NSUserInterfaceItemIdentifier("devices-variants") + window.isReleasedWhenClosed = false + window.contentView = NSHostingView(rootView: content) + window.makeKeyAndOrderFront(nil) + NSApp.activate() + self.window = window + } + + static func hide() { + window?.close() + window = nil + NSApp.windows.first { $0.identifier?.rawValue == "settings" }?.makeKeyAndOrderFront(nil) + } +} + +@Observable +@MainActor +private final class VariantModel { + var pairing: Int? + var restarting = false + var offersReset = false + var confirming = false + var issue: String? + var items: [Int] = [] + var visible = true + var canPair: Bool { pairing == nil && !confirming && items.count < 10 } + var isSheetPresented: Bool { pairing != nil || restarting } + func opened() async { items = [] } +} + +private struct SheetItem: Identifiable { let id: Int } + +/// The outer view, like `DevicesSettingsSection`: an optional model in `@State`. +private struct VariantSection: View { + let identifier: String + let report: @MainActor (String) -> Void + @State private var model: VariantModel? = VariantModel() + + var body: some View { + if identifier == "no-outer" { + VariantContent(model: VariantModel(), identifier: identifier, report: report) + } else if let model, model.visible { + VariantContent(model: model, identifier: identifier, report: report) + } + } +} + +/// `DevicesSectionContent` with one difference left out, named by `identifier`; "backup" is the Backup row. +private struct VariantContent: View { + @Bindable var model: VariantModel + let identifier: String + let report: @MainActor (String) -> Void + @State private var item: SheetItem? + + var body: some View { + VariantGroup(header: identifier) { + row + if model.confirming { Text("Confirm") } + ForEach(model.items, id: \.self) { Text("\($0)") } + if let issue = model.issue { Text(issue) } + } + .onAppear { Task { await model.opened() } } + } + + @ViewBuilder private var row: some View { + switch identifier { + case "no-sheet": VariantRow { control } + case "item-sheet", "backup": VariantRow { control }.sheet(item: $item) { _ in Text("Sheet") } + default: + VariantRow { control } + .sheet(isPresented: Binding(get: { model.isSheetPresented }, set: { if !$0 { model.pairing = nil } })) { Text("Sheet") } + } + } + + @ViewBuilder private var control: some View { + if identifier == "no-branch" || identifier == "backup" { + button + } else if model.offersReset { + Button("Reset", role: .destructive) {}.buttonStyle(VariantButtonStyle()) + } else { + button + } + } + + @ViewBuilder private var button: some View { + let base = Button("Pair \(identifier)", action: fire).buttonStyle(VariantButtonStyle()) + switch identifier { + case "no-dimming", "backup": base.accessibilityIdentifier("variant-\(identifier)") + case "disabled-only": base.disabled(!model.canPair).accessibilityIdentifier("variant-\(identifier)") + default: base.disabled(!model.canPair).opacity(model.canPair ? 1 : 0.45).accessibilityIdentifier("variant-\(identifier)") + } + } + + private func fire() { + Task { report("variant \(identifier) fired") } + } +} + +private struct VariantGroup: View { + let header: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(header).font(.caption) + VStack(spacing: 0) { content } + .background(Color.white, in: .rect(cornerRadius: 10)) + .clipShape(.rect(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gray.opacity(0.3), lineWidth: 1)) + } + } +} + +private struct VariantRow: View { + @ViewBuilder let control: Control + + var body: some View { + HStack(alignment: .center, spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text("Pairing") + Text("Pair Ask Key on your iPhone with this Mac.").font(.caption).fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 12) + control + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(minHeight: 44) + } +} + +private struct VariantButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .lineLimit(1) + .padding(.horizontal, 12) + .frame(height: 30) + .background(configuration.isPressed ? Color.black.opacity(0.06) : Color.white, in: .rect(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.black.opacity(0.16), lineWidth: 1)) + .contentShape(Rectangle()) + } +} diff --git a/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift b/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift index 63eaf29..4e4fe47 100644 --- a/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift +++ b/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift @@ -101,6 +101,23 @@ enum E2EDeviceDiagnostics { return "posted \(events.count) events at \(location) in \(name(window))" } + /// The accessibility element at a point in XCTest's screen coordinates, then its press action, + /// which runs a button's action without any mouse event. + static func pressAccessibilityElement(atRunnerPoint point: NSPoint) async -> String { + guard let screen = NSScreen.screens.first else { return "no screen" } + let screenPoint = NSPoint(x: point.x, y: screen.frame.maxY - point.y) + guard let window = NSApp.windows.first(where: { $0.isVisible && $0.level == .normal && $0.frame.contains(screenPoint) }) else { + return "no window at \(screenPoint)" + } + guard let element = window.accessibilityHitTest(screenPoint) as? NSObject else { return "no element at \(screenPoint)" } + let role = element.accessibilityAttributeValue(.role) as? String ?? "-" + let identifier = element.accessibilityAttributeValue(.identifier) as? String ?? "" + let actions = element.accessibilityActionNames() + element.accessibilityPerformAction(.press) + try? await Task.sleep(for: .milliseconds(500)) + return "pressed \(type(of: element)) role=\(role) id=\(identifier) actions=\(actions.map(\.rawValue))" + } + /// The AppKit views under the centre of a screen rectangle, in each visible window containing it. private static func viewsUnder(_ screenFrame: NSRect) -> String { let point = NSPoint(x: screenFrame.midX, y: screenFrame.midY) diff --git a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift index 034db9c..48e8dca 100644 --- a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift +++ b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift @@ -14,7 +14,7 @@ import Observation enum E2EDeviceScenario { static let scenarioPrefix = "screenshots-devices" static let phoneName = "Synthetic iPhone" - static let commands = ["snapshot", "start-pairing", "phone-scan", "phone-reveal", "phone-complete", "phone-poll", "account-signed-out", "ax-walk", "resign-focus", "inject-click"] + static let commands = ["snapshot", "start-pairing", "phone-scan", "phone-reveal", "phone-complete", "phone-poll", "account-signed-out", "ax-walk", "resign-focus", "inject-click", "ax-press", "show-variants", "hide-variants"] private static var backend: FakeDeviceServicesBackend? private static var phone: SimulatedPairingPhone? @@ -172,6 +172,21 @@ enum E2EDeviceScenario { let outcome = await E2EDeviceDiagnostics.postClick(atRunnerPoint: NSPoint(x: values[0], y: values[1])) transitions.append("\(Date().timeIntervalSince1970) posted click: \(outcome)") if let control { writeTransitions(control: control) } + case "ax-press": + // Diagnostic only (#298): the accessibility press of the element at a runner point. + let values = argument.split(whereSeparator: \.isWhitespace).compactMap { Double($0) } + guard values.count == 2 else { throw E2EDeviceScenarioError.unknownCommand } + let outcome = await E2EDeviceDiagnostics.pressAccessibilityElement(atRunnerPoint: NSPoint(x: values[0], y: values[1])) + transitions.append("\(Date().timeIntervalSince1970) accessibility press: \(outcome)") + if let control { writeTransitions(control: control) } + case "show-variants": + // Diagnostic only (#298): replicas of the Devices row in a separate window. + E2EDeviceButtonVariants.show { line in + transitions.append("\(Date().timeIntervalSince1970) \(line)") + if let control { writeTransitions(control: control) } + } + case "hide-variants": + E2EDeviceButtonVariants.hide() case "account-signed-out": backend.accountState = .noAccount backend.signalAccountChange() From 986d42dbb2af0d9e2acd7bc22f551e4fdac19923 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Wed, 11 Nov 2026 00:31:39 +0800 Subject: [PATCH 10/21] Scan accessibility hit tests around the Devices buttons in the E2E --- .../ScreenshotE2ETests+Devices.swift | 20 +-- .../E2EDeviceButtonVariants.swift | 168 ------------------ .../E2EDeviceDiagnostics.swift | 29 +++ .../AskKeyTestSupport/E2EDeviceScenario.swift | 17 +- 4 files changed, 46 insertions(+), 188 deletions(-) delete mode 100644 Tests/AskKeyTestSupport/E2EDeviceButtonVariants.swift diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift index 80ed054..f8f5f0f 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift @@ -38,17 +38,6 @@ extension ScreenshotE2ETests { app.launch() addTeardownBlock { [app] in app?.terminate() } // a hung query must not also hang the teardown moveManagementAboveDock() - // #298 diagnostics: replicas of the Devices row, each without one of its differences. - try fixture("show-variants") - let variants = app.windows["devices-variants"].firstMatch - XCTAssertTrue(variants.waitForExistence(timeout: 8)) - for identifier in ["full", "no-sheet", "item-sheet", "no-dimming", "disabled-only", "no-branch", "no-outer", "backup"] { - let button = variants.buttons["variant-\(identifier)"].firstMatch - notes.append("variant \(identifier): hittable=\(button.isHittable)") - if button.isHittable { button.click() } - } - Thread.sleep(forTimeInterval: 1) - try fixture("hide-variants") click("unlock-management") click("sidebar-settings") let pair = app.buttons["settings-pair-device"].firstMatch @@ -63,6 +52,8 @@ extension ScreenshotE2ETests { // session if none of them opened the sheet. let export = app.buttons["settings-export-backup"].firstMatch notes.append("export: hittable=\(export.isHittable) frame=\(export.frame)") + try fixture("ax-scan", "\(export.frame.midX) \(export.frame.midY)") + try fixture("ax-scan", "\(pair.frame.midX) \(pair.frame.midY)") export.click() if sheet.waitForExistence(timeout: 5) { notes.append("export sheet") @@ -110,6 +101,13 @@ extension ScreenshotE2ETests { func textQuery(_ text: String, in root: XCUIElement) -> XCUIElement { root.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS %@ OR value CONTAINS %@", text, text)).firstMatch } + let rename = app.buttons["devices-rename"].firstMatch + XCTAssertTrue(rename.waitForExistence(timeout: 8)) + try fixture("ax-scan", "\(rename.frame.midX) \(rename.frame.midY)") + attachDeviceDiagnostics(notes) + let sidebar = app.buttons["sidebar-settings"].firstMatch + timed("sidebar text query after Done") { textQuery("Settings", in: sidebar).waitForExistence(timeout: 2) } + attachDeviceDiagnostics(notes) timed("Settings window text query after Done") { textQuery(lastReached, in: settings).waitForExistence(timeout: 8) } attachDeviceDiagnostics(notes) let row = app.buttons["devices-rename"].firstMatch diff --git a/Tests/AskKeyTestSupport/E2EDeviceButtonVariants.swift b/Tests/AskKeyTestSupport/E2EDeviceButtonVariants.swift deleted file mode 100644 index ea925fc..0000000 --- a/Tests/AskKeyTestSupport/E2EDeviceButtonVariants.swift +++ /dev/null @@ -1,168 +0,0 @@ -import AppKit -import Observation -import SwiftUI - -/// Diagnostics for #298, in the E2E App only: on macOS 26, clicks on Pair a Device… do not reach -/// its action, while Export Backup… in the same page works. This window shows replicas of the -/// Devices row, each without one of its differences from the Backup row, so a run shows which -/// difference keeps a click from the button. -@MainActor -enum E2EDeviceButtonVariants { - private static var window: NSWindow? - - /// The replicas: each identifier's button reports `variant fired` when its action runs. - static let identifiers = ["full", "no-sheet", "item-sheet", "no-dimming", "disabled-only", "no-branch", "no-outer", "backup"] - - static func show(_ report: @escaping @MainActor (String) -> Void) { - let content = ScrollView { - VStack(alignment: .leading, spacing: 12) { - ForEach(identifiers, id: \.self) { identifier in - VariantSection(identifier: identifier, report: report) - } - } - .padding(16) - } - let window = NSWindow(contentRect: NSRect(x: 60, y: 40, width: 560, height: 700), - styleMask: [.titled, .closable], backing: .buffered, defer: false) - window.identifier = NSUserInterfaceItemIdentifier("devices-variants") - window.isReleasedWhenClosed = false - window.contentView = NSHostingView(rootView: content) - window.makeKeyAndOrderFront(nil) - NSApp.activate() - self.window = window - } - - static func hide() { - window?.close() - window = nil - NSApp.windows.first { $0.identifier?.rawValue == "settings" }?.makeKeyAndOrderFront(nil) - } -} - -@Observable -@MainActor -private final class VariantModel { - var pairing: Int? - var restarting = false - var offersReset = false - var confirming = false - var issue: String? - var items: [Int] = [] - var visible = true - var canPair: Bool { pairing == nil && !confirming && items.count < 10 } - var isSheetPresented: Bool { pairing != nil || restarting } - func opened() async { items = [] } -} - -private struct SheetItem: Identifiable { let id: Int } - -/// The outer view, like `DevicesSettingsSection`: an optional model in `@State`. -private struct VariantSection: View { - let identifier: String - let report: @MainActor (String) -> Void - @State private var model: VariantModel? = VariantModel() - - var body: some View { - if identifier == "no-outer" { - VariantContent(model: VariantModel(), identifier: identifier, report: report) - } else if let model, model.visible { - VariantContent(model: model, identifier: identifier, report: report) - } - } -} - -/// `DevicesSectionContent` with one difference left out, named by `identifier`; "backup" is the Backup row. -private struct VariantContent: View { - @Bindable var model: VariantModel - let identifier: String - let report: @MainActor (String) -> Void - @State private var item: SheetItem? - - var body: some View { - VariantGroup(header: identifier) { - row - if model.confirming { Text("Confirm") } - ForEach(model.items, id: \.self) { Text("\($0)") } - if let issue = model.issue { Text(issue) } - } - .onAppear { Task { await model.opened() } } - } - - @ViewBuilder private var row: some View { - switch identifier { - case "no-sheet": VariantRow { control } - case "item-sheet", "backup": VariantRow { control }.sheet(item: $item) { _ in Text("Sheet") } - default: - VariantRow { control } - .sheet(isPresented: Binding(get: { model.isSheetPresented }, set: { if !$0 { model.pairing = nil } })) { Text("Sheet") } - } - } - - @ViewBuilder private var control: some View { - if identifier == "no-branch" || identifier == "backup" { - button - } else if model.offersReset { - Button("Reset", role: .destructive) {}.buttonStyle(VariantButtonStyle()) - } else { - button - } - } - - @ViewBuilder private var button: some View { - let base = Button("Pair \(identifier)", action: fire).buttonStyle(VariantButtonStyle()) - switch identifier { - case "no-dimming", "backup": base.accessibilityIdentifier("variant-\(identifier)") - case "disabled-only": base.disabled(!model.canPair).accessibilityIdentifier("variant-\(identifier)") - default: base.disabled(!model.canPair).opacity(model.canPair ? 1 : 0.45).accessibilityIdentifier("variant-\(identifier)") - } - } - - private func fire() { - Task { report("variant \(identifier) fired") } - } -} - -private struct VariantGroup: View { - let header: String - @ViewBuilder let content: Content - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text(header).font(.caption) - VStack(spacing: 0) { content } - .background(Color.white, in: .rect(cornerRadius: 10)) - .clipShape(.rect(cornerRadius: 10)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gray.opacity(0.3), lineWidth: 1)) - } - } -} - -private struct VariantRow: View { - @ViewBuilder let control: Control - - var body: some View { - HStack(alignment: .center, spacing: 16) { - VStack(alignment: .leading, spacing: 2) { - Text("Pairing") - Text("Pair Ask Key on your iPhone with this Mac.").font(.caption).fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 12) - control - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .frame(minHeight: 44) - } -} - -private struct VariantButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .lineLimit(1) - .padding(.horizontal, 12) - .frame(height: 30) - .background(configuration.isPressed ? Color.black.opacity(0.06) : Color.white, in: .rect(cornerRadius: 6)) - .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.black.opacity(0.16), lineWidth: 1)) - .contentShape(Rectangle()) - } -} diff --git a/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift b/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift index 4e4fe47..c8d287f 100644 --- a/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift +++ b/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift @@ -118,6 +118,35 @@ enum E2EDeviceDiagnostics { return "pressed \(type(of: element)) role=\(role) id=\(identifier) actions=\(actions.map(\.rawValue))" } + /// Accessibility hit tests along a vertical and a horizontal line through a point in XCTest's + /// screen coordinates: which element SwiftUI reports at each offset, runs of the same merged. + static func scanAccessibility(aroundRunnerPoint point: NSPoint) -> String { + guard let screen = NSScreen.screens.first else { return "no screen" } + func element(at offset: CGSize) -> String { + let screenPoint = NSPoint(x: point.x + offset.width, y: screen.frame.maxY - point.y - offset.height) + guard let window = NSApp.windows.first(where: { $0.isVisible && $0.level == .normal && $0.frame.contains(screenPoint) }), + let hit = window.accessibilityHitTest(screenPoint) as? NSObject else { return "none" } + let role = hit.accessibilityAttributeValue(.role) as? String ?? "-" + let identifier = hit.accessibilityAttributeValue(.identifier) as? String ?? "" + let label = hit.accessibilityAttributeValue(NSAccessibility.Attribute(rawValue: "AXDescription")) as? String + ?? hit.accessibilityAttributeValue(.value) as? String ?? "" + return "\(role) \(identifier.isEmpty ? String(label.prefix(24)) : identifier)" + } + func scan(_ offsets: [CGSize], _ axis: KeyPath) -> String { + var runs: [(from: CGFloat, to: CGFloat, what: String)] = [] + for offset in offsets { + let what = element(at: offset) + if let last = runs.last, last.what == what { runs[runs.count - 1].to = offset[keyPath: axis] } + else { runs.append((offset[keyPath: axis], offset[keyPath: axis], what)) } + } + return runs.map { "\(Int($0.from))…\(Int($0.to)): \($0.what)" }.joined(separator: " | ") + } + // Down the screen is positive, as in XCTest's coordinates. + let vertical = scan(stride(from: -200, through: 200, by: 4).map { CGSize(width: 0, height: CGFloat($0)) }, \.height) + let horizontal = scan(stride(from: -240, through: 120, by: 8).map { CGSize(width: CGFloat($0), height: 0) }, \.width) + return "vertical [\(vertical)] horizontal [\(horizontal)]" + } + /// The AppKit views under the centre of a screen rectangle, in each visible window containing it. private static func viewsUnder(_ screenFrame: NSRect) -> String { let point = NSPoint(x: screenFrame.midX, y: screenFrame.midY) diff --git a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift index 48e8dca..7f042c3 100644 --- a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift +++ b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift @@ -14,7 +14,7 @@ import Observation enum E2EDeviceScenario { static let scenarioPrefix = "screenshots-devices" static let phoneName = "Synthetic iPhone" - static let commands = ["snapshot", "start-pairing", "phone-scan", "phone-reveal", "phone-complete", "phone-poll", "account-signed-out", "ax-walk", "resign-focus", "inject-click", "ax-press", "show-variants", "hide-variants"] + static let commands = ["snapshot", "start-pairing", "phone-scan", "phone-reveal", "phone-complete", "phone-poll", "account-signed-out", "ax-walk", "resign-focus", "inject-click", "ax-press", "ax-scan"] private static var backend: FakeDeviceServicesBackend? private static var phone: SimulatedPairingPhone? @@ -179,14 +179,13 @@ enum E2EDeviceScenario { let outcome = await E2EDeviceDiagnostics.pressAccessibilityElement(atRunnerPoint: NSPoint(x: values[0], y: values[1])) transitions.append("\(Date().timeIntervalSince1970) accessibility press: \(outcome)") if let control { writeTransitions(control: control) } - case "show-variants": - // Diagnostic only (#298): replicas of the Devices row in a separate window. - E2EDeviceButtonVariants.show { line in - transitions.append("\(Date().timeIntervalSince1970) \(line)") - if let control { writeTransitions(control: control) } - } - case "hide-variants": - E2EDeviceButtonVariants.hide() + case "ax-scan": + // Diagnostic only (#298): accessibility hit tests around a runner point. + let values = argument.split(whereSeparator: \.isWhitespace).compactMap { Double($0) } + guard values.count == 2 else { throw E2EDeviceScenarioError.unknownCommand } + transitions.append("\(Date().timeIntervalSince1970) accessibility scan around \(values): " + + E2EDeviceDiagnostics.scanAccessibility(aroundRunnerPoint: NSPoint(x: values[0], y: values[1]))) + if let control { writeTransitions(control: control) } case "account-signed-out": backend.accountState = .noAccount backend.signalAccountChange() From b6adc29042f251c099b3ff86fcc9d75540a8b44f Mon Sep 17 00:00:00 2001 From: sudoHG Date: Wed, 11 Nov 2026 01:03:15 +0800 Subject: [PATCH 11/21] Record the E2E App's own log and standard error around the Devices clicks --- .../ScreenshotE2ETests+Devices.swift | 11 ++- .../E2EDeviceDiagnostics.swift | 79 ++++++++----------- .../AskKeyTestSupport/E2EDeviceScenario.swift | 22 ++---- 3 files changed, 47 insertions(+), 65 deletions(-) diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift index f8f5f0f..3b53178 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift @@ -52,8 +52,6 @@ extension ScreenshotE2ETests { // session if none of them opened the sheet. let export = app.buttons["settings-export-backup"].firstMatch notes.append("export: hittable=\(export.isHittable) frame=\(export.frame)") - try fixture("ax-scan", "\(export.frame.midX) \(export.frame.midY)") - try fixture("ax-scan", "\(pair.frame.midX) \(pair.frame.midY)") export.click() if sheet.waitForExistence(timeout: 5) { notes.append("export sheet") @@ -76,10 +74,13 @@ extension ScreenshotE2ETests { notes.append(clickOpened.last == true ? "sheet" : "no sheet") } let centre = "\(pair.frame.midX) \(pair.frame.midY)" - for command in ["inject-click", "ax-press"] where !sheet.exists { + for command in ["inject-click"] where !sheet.exists { try fixture(command, centre) notes.append("\(command) at \(centre): " + (sheet.waitForExistence(timeout: 3) ? "sheet" : "no sheet")) } + try fixture("write-log") + attachDeviceFile("device-log.txt") + attachDeviceFile("device-stderr.txt") if !sheet.exists { notes.append("fixture start"); try fixture("start-pairing") } XCTAssertTrue(sheet.waitForExistence(timeout: 8), "No pairing sheet") attachDeviceDiagnostics(notes) @@ -103,7 +104,9 @@ extension ScreenshotE2ETests { } let rename = app.buttons["devices-rename"].firstMatch XCTAssertTrue(rename.waitForExistence(timeout: 8)) - try fixture("ax-scan", "\(rename.frame.midX) \(rename.frame.midY)") + try fixture("write-log") + attachDeviceFile("device-log.txt") + attachDeviceFile("device-stderr.txt") attachDeviceDiagnostics(notes) let sidebar = app.buttons["sidebar-settings"].firstMatch timed("sidebar text query after Done") { textQuery("Settings", in: sidebar).waitForExistence(timeout: 2) } diff --git a/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift b/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift index c8d287f..c7996f8 100644 --- a/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift +++ b/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift @@ -1,4 +1,5 @@ import AppKit +import OSLog /// Diagnostics for #298, in the E2E App only: XCTest's click on Pair a Device… opened no sheet, /// and full-tree queries timed out after a pairing. They record which window and views receive @@ -9,6 +10,38 @@ enum E2EDeviceDiagnostics { private static var monitor: Any? private static var walks = 0 + /// Sends the App's standard error to `device-stderr.txt`, where SwiftUI and AttributeGraph + /// print some of their runtime warnings. + static func captureStandardError(control: URL) { + let path = control.appendingPathComponent("device-stderr.txt").path + guard FileManager.default.createFile(atPath: path, contents: nil), + let handle = FileHandle(forWritingAtPath: path) else { return } + dup2(handle.fileDescriptor, STDERR_FILENO) + } + + /// Writes the App's own unified log since launch to `device-log.txt` and returns how many + /// entries came from SwiftUI, AppKit and AttributeGraph, or mention a cycle. + static func writeLog(control: URL) -> String { + do { + let store = try OSLogStore(scope: .currentProcessIdentifier) + var lines: [String] = [] + var frameworks = 0, cycles = 0 + for case let entry as OSLogEntryLog in try store.getEntries() { + let subsystem = entry.subsystem + let message = entry.composedMessage + let framework = subsystem.hasPrefix("com.apple.SwiftUI") || subsystem.hasPrefix("com.apple.AppKit") + || subsystem.lowercased().contains("attributegraph") || message.contains("AttributeGraph") + if framework { frameworks += 1 } + if message.lowercased().contains("cycle") { cycles += 1 } + lines.append("\(entry.date.timeIntervalSince1970) [\(subsystem)/\(entry.category)] \(message)") + } + try lines.suffix(5_000).joined(separator: "\n").write(to: control.appendingPathComponent("device-log.txt"), atomically: true, encoding: .utf8) + return "log entries=\(lines.count) framework=\(frameworks) cycle=\(cycles)" + } catch { + return "log unavailable: \(error)" + } + } + /// Reports each left mouse down and up the App receives. static func recordMouseEvents(_ report: @escaping @MainActor (String) -> Void) { guard monitor == nil else { return } @@ -101,52 +134,6 @@ enum E2EDeviceDiagnostics { return "posted \(events.count) events at \(location) in \(name(window))" } - /// The accessibility element at a point in XCTest's screen coordinates, then its press action, - /// which runs a button's action without any mouse event. - static func pressAccessibilityElement(atRunnerPoint point: NSPoint) async -> String { - guard let screen = NSScreen.screens.first else { return "no screen" } - let screenPoint = NSPoint(x: point.x, y: screen.frame.maxY - point.y) - guard let window = NSApp.windows.first(where: { $0.isVisible && $0.level == .normal && $0.frame.contains(screenPoint) }) else { - return "no window at \(screenPoint)" - } - guard let element = window.accessibilityHitTest(screenPoint) as? NSObject else { return "no element at \(screenPoint)" } - let role = element.accessibilityAttributeValue(.role) as? String ?? "-" - let identifier = element.accessibilityAttributeValue(.identifier) as? String ?? "" - let actions = element.accessibilityActionNames() - element.accessibilityPerformAction(.press) - try? await Task.sleep(for: .milliseconds(500)) - return "pressed \(type(of: element)) role=\(role) id=\(identifier) actions=\(actions.map(\.rawValue))" - } - - /// Accessibility hit tests along a vertical and a horizontal line through a point in XCTest's - /// screen coordinates: which element SwiftUI reports at each offset, runs of the same merged. - static func scanAccessibility(aroundRunnerPoint point: NSPoint) -> String { - guard let screen = NSScreen.screens.first else { return "no screen" } - func element(at offset: CGSize) -> String { - let screenPoint = NSPoint(x: point.x + offset.width, y: screen.frame.maxY - point.y - offset.height) - guard let window = NSApp.windows.first(where: { $0.isVisible && $0.level == .normal && $0.frame.contains(screenPoint) }), - let hit = window.accessibilityHitTest(screenPoint) as? NSObject else { return "none" } - let role = hit.accessibilityAttributeValue(.role) as? String ?? "-" - let identifier = hit.accessibilityAttributeValue(.identifier) as? String ?? "" - let label = hit.accessibilityAttributeValue(NSAccessibility.Attribute(rawValue: "AXDescription")) as? String - ?? hit.accessibilityAttributeValue(.value) as? String ?? "" - return "\(role) \(identifier.isEmpty ? String(label.prefix(24)) : identifier)" - } - func scan(_ offsets: [CGSize], _ axis: KeyPath) -> String { - var runs: [(from: CGFloat, to: CGFloat, what: String)] = [] - for offset in offsets { - let what = element(at: offset) - if let last = runs.last, last.what == what { runs[runs.count - 1].to = offset[keyPath: axis] } - else { runs.append((offset[keyPath: axis], offset[keyPath: axis], what)) } - } - return runs.map { "\(Int($0.from))…\(Int($0.to)): \($0.what)" }.joined(separator: " | ") - } - // Down the screen is positive, as in XCTest's coordinates. - let vertical = scan(stride(from: -200, through: 200, by: 4).map { CGSize(width: 0, height: CGFloat($0)) }, \.height) - let horizontal = scan(stride(from: -240, through: 120, by: 8).map { CGSize(width: CGFloat($0), height: 0) }, \.width) - return "vertical [\(vertical)] horizontal [\(horizontal)]" - } - /// The AppKit views under the centre of a screen rectangle, in each visible window containing it. private static func viewsUnder(_ screenFrame: NSRect) -> String { let point = NSPoint(x: screenFrame.midX, y: screenFrame.midY) diff --git a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift index 7f042c3..f61ffe7 100644 --- a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift +++ b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift @@ -14,7 +14,7 @@ import Observation enum E2EDeviceScenario { static let scenarioPrefix = "screenshots-devices" static let phoneName = "Synthetic iPhone" - static let commands = ["snapshot", "start-pairing", "phone-scan", "phone-reveal", "phone-complete", "phone-poll", "account-signed-out", "ax-walk", "resign-focus", "inject-click", "ax-press", "ax-scan"] + static let commands = ["snapshot", "start-pairing", "phone-scan", "phone-reveal", "phone-complete", "phone-poll", "account-signed-out", "ax-walk", "resign-focus", "inject-click", "write-log"] private static var backend: FakeDeviceServicesBackend? private static var phone: SimulatedPairingPhone? @@ -38,6 +38,7 @@ enum E2EDeviceScenario { /// and writes `device-.json` with the outcome. static func start(control: URL) { self.control = control + E2EDeviceDiagnostics.captureStandardError(control: control) recordPairingTransitions(control: control) E2EDeviceDiagnostics.recordMouseEvents { event in transitions.append("\(Date().timeIntervalSince1970) \(event)") @@ -172,20 +173,11 @@ enum E2EDeviceScenario { let outcome = await E2EDeviceDiagnostics.postClick(atRunnerPoint: NSPoint(x: values[0], y: values[1])) transitions.append("\(Date().timeIntervalSince1970) posted click: \(outcome)") if let control { writeTransitions(control: control) } - case "ax-press": - // Diagnostic only (#298): the accessibility press of the element at a runner point. - let values = argument.split(whereSeparator: \.isWhitespace).compactMap { Double($0) } - guard values.count == 2 else { throw E2EDeviceScenarioError.unknownCommand } - let outcome = await E2EDeviceDiagnostics.pressAccessibilityElement(atRunnerPoint: NSPoint(x: values[0], y: values[1])) - transitions.append("\(Date().timeIntervalSince1970) accessibility press: \(outcome)") - if let control { writeTransitions(control: control) } - case "ax-scan": - // Diagnostic only (#298): accessibility hit tests around a runner point. - let values = argument.split(whereSeparator: \.isWhitespace).compactMap { Double($0) } - guard values.count == 2 else { throw E2EDeviceScenarioError.unknownCommand } - transitions.append("\(Date().timeIntervalSince1970) accessibility scan around \(values): " - + E2EDeviceDiagnostics.scanAccessibility(aroundRunnerPoint: NSPoint(x: values[0], y: values[1]))) - if let control { writeTransitions(control: control) } + case "write-log": + // Diagnostic only (#298): the App's own log and standard error so far. + guard let control else { throw E2EDeviceScenarioError.notInstalled } + transitions.append("\(Date().timeIntervalSince1970) \(E2EDeviceDiagnostics.writeLog(control: control))") + writeTransitions(control: control) case "account-signed-out": backend.accountState = .noAccount backend.signalAccountChange() From a6bf61f36c26407be229d246f9b4be0b094066b0 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Wed, 11 Nov 2026 01:32:50 +0800 Subject: [PATCH 12/21] Filter the E2E App log, record it on failure and click the Devices section again after pairing --- .../ScreenshotE2ETests+Devices.swift | 20 ++++++++++++++++++- .../E2EDeviceDiagnostics.swift | 5 ++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift index 3b53178..aa6345a 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift @@ -37,6 +37,11 @@ extension ScreenshotE2ETests { app.launchEnvironment["ASKKEY_E2E_LANGUAGE"] = language app.launch() addTeardownBlock { [app] in app?.terminate() } // a hung query must not also hang the teardown + addTeardownBlock { // runs first: the App's log up to a failure + try? fixture("write-log") + self.attachDeviceFile("device-log.txt") + self.attachDeviceDiagnostics(notes) + } moveManagementAboveDock() click("unlock-management") click("sidebar-settings") @@ -106,7 +111,20 @@ extension ScreenshotE2ETests { XCTAssertTrue(rename.waitForExistence(timeout: 8)) try fixture("write-log") attachDeviceFile("device-log.txt") - attachDeviceFile("device-stderr.txt") + // #298 diagnostics: whether the Devices section takes clicks after a pairing. + for _ in 0..<6 where !rename.isHittable { page.scroll(byDeltaX: 0, deltaY: -300) } + rename.click() + notes.append("rename after pairing: field=\(app.textFields["devices-rename-field"].waitForExistence(timeout: 3))") + app.typeKey(.escape, modifierFlags: []) + notes.append("pair after pairing: hittable=\(pair.isHittable) frame=\(pair.frame)") + if pair.isHittable { pair.click() } + if sheet.waitForExistence(timeout: 3) { + notes.append("pair after pairing: sheet") + click("devices-pairing-cancel") + notes.append("sheet closed=\(sheet.waitForNonExistence(timeout: 5))") + } else { + notes.append("pair after pairing: no sheet") + } attachDeviceDiagnostics(notes) let sidebar = app.buttons["sidebar-settings"].firstMatch timed("sidebar text query after Done") { textQuery("Settings", in: sidebar).waitForExistence(timeout: 2) } diff --git a/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift b/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift index c7996f8..c974d4d 100644 --- a/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift +++ b/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift @@ -26,16 +26,19 @@ enum E2EDeviceDiagnostics { let store = try OSLogStore(scope: .currentProcessIdentifier) var lines: [String] = [] var frameworks = 0, cycles = 0 + // XCTest's in-App automation logs every element it reads; those lines are left out. + let noise = ["faulted in", "Fetching AX attributes", "Got attributes", "Computed elementType", "Automation type mismatch"] for case let entry as OSLogEntryLog in try store.getEntries() { let subsystem = entry.subsystem let message = entry.composedMessage + if noise.contains(where: message.contains) { continue } let framework = subsystem.hasPrefix("com.apple.SwiftUI") || subsystem.hasPrefix("com.apple.AppKit") || subsystem.lowercased().contains("attributegraph") || message.contains("AttributeGraph") if framework { frameworks += 1 } if message.lowercased().contains("cycle") { cycles += 1 } lines.append("\(entry.date.timeIntervalSince1970) [\(subsystem)/\(entry.category)] \(message)") } - try lines.suffix(5_000).joined(separator: "\n").write(to: control.appendingPathComponent("device-log.txt"), atomically: true, encoding: .utf8) + try lines.suffix(20_000).joined(separator: "\n").write(to: control.appendingPathComponent("device-log.txt"), atomically: true, encoding: .utf8) return "log entries=\(lines.count) framework=\(frameworks) cycle=\(cycles)" } catch { return "log unavailable: \(error)" From 0f547e76bf6f5b425d4eb240eecc1e6f4dcf9a93 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Wed, 11 Nov 2026 02:03:47 +0800 Subject: [PATCH 13/21] TEMP #298 diagnostics, revert before merge: print view changes and bisect the Devices row Identify the Devices status lines and the Settings page for the E2E, which now finds them by identifier. --- .../Settings/BackupSettingsSection.swift | 1 + .../Views/Settings/DevicePairingSheet.swift | 1 + .../Settings/DevicesSettingsSection.swift | 97 +++++++++++++------ .../Views/Settings/FrozenSettingsPage.swift | 2 + .../ScreenshotE2ETests+Devices.swift | 61 ++++-------- .../E2EDeviceDiagnostics.swift | 7 ++ 6 files changed, 97 insertions(+), 72 deletions(-) diff --git a/Sources/AskKeyAppKit/Views/Settings/BackupSettingsSection.swift b/Sources/AskKeyAppKit/Views/Settings/BackupSettingsSection.swift index ef1e06f..103a9ae 100644 --- a/Sources/AskKeyAppKit/Views/Settings/BackupSettingsSection.swift +++ b/Sources/AskKeyAppKit/Views/Settings/BackupSettingsSection.swift @@ -9,6 +9,7 @@ struct BackupSettingsSection: View { @State private var savedFileName: String? var body: some View { + let _ = Self._printChanges() // T6 TEMP #298, revert before merge // A separate view keeps its body across the parent's redraw, so it // subscribes to the language itself; otherwise a switch leaves this // section in the previous language. diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift b/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift index 844008a..f94b7f1 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift @@ -11,6 +11,7 @@ struct DevicePairingSheet: View { @FocusState private var codeFocused: Bool var body: some View { + let _ = Self._printChanges() // T6 TEMP #298, revert before merge let _ = AppLanguage.store.resolved let sheet = model.sheet ?? shown Group { diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift index fd6f185..7cb7fc5 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift @@ -17,6 +17,7 @@ struct DevicesSettingsSection: View { } var body: some View { + let _ = Self._printChanges() // T6 TEMP #298, revert before merge // A separate view keeps its body across the parent's redraw, so it // subscribes to the language itself. let _ = AppLanguage.store.resolved @@ -28,37 +29,22 @@ struct DevicesSettingsSection: View { private struct DevicesSectionContent: View { @Bindable var model: DevicesSettingsModel + // T6 TEMP #298 diagnostics, revert before merge: which half of the differences from the + // Backup row this run leaves out. + private let variant = ProcessInfo.processInfo.environment["ASKKEY_T6_VARIANT"] ?? "" var body: some View { + let _ = Self._printChanges() // 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) + let group = GroupedList(header: appLocalized("Devices")) { + if variant == "outer-sheet" { + pairingRow(notice) + } else { + pairingRow(notice) + .sheet(isPresented: sheetBinding) { DevicePairingSheet(model: model) } } if model.confirmingReset { GroupedListSeparator() @@ -86,7 +72,49 @@ private struct DevicesSectionContent: View { .accessibilityIdentifier("settings-devices-issue") } } - .onAppear { Task { await model.sectionOpened() } } + if variant == "outer-sheet" { + group + .sheet(isPresented: sheetBinding) { DevicePairingSheet(model: model) } + .onAppear { Task { await model.sectionOpened() } } + } else if variant == "plain" { + group + } else { + group.onAppear { Task { await model.sectionOpened() } } + } + } + + private func pairingRow(_ notice: DevicesPresentation.Notice) -> some View { + DevicesSettingRow(title: appLocalized("Pairing"), message: notice.text, isWarning: notice.isWarning) { + if variant == "plain" { + Button(appLocalized("Pair a Device…"), action: pairDevice) + .buttonStyle(.secondaryAction) + .disabled(!model.canPair) + .accessibilityIdentifier("settings-pair-device") + } else 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. + private var sheetBinding: Binding { + Binding( + get: { model.isSheetPresented }, + set: { presented in + if !presented, model.coordinator.pairing?.isActive != true { Task { await model.closeSheet() } } + } + ) } private func pairDevice() { @@ -133,11 +161,11 @@ private struct DeviceRow: View { Text(DevicesPresentation.rowSummary(row)) .font(Theme.Fonts.secondary) .foregroundStyle(Theme.textSecondary) - ForEach([reachability, check].compactMap { $0 }, id: \.text) { line in - Text(line.text) - .font(Theme.Fonts.secondary) - .foregroundStyle(line.isWarning ? Theme.warning : Theme.textSecondary) - .fixedSize(horizontal: false, vertical: true) + statusLine(reachability) + .accessibilityIdentifier("devices-row-reachability") + if let check { + statusLine(check) + .accessibilityIdentifier("devices-row-delivery-check") } } Spacer(minLength: Theme.Spacing.md) @@ -177,6 +205,13 @@ private struct DeviceRow: View { } } } + + 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 diff --git a/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift b/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift index 52644be..2686192 100644 --- a/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift +++ b/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift @@ -28,6 +28,7 @@ struct FrozenSettingsPage: View { } var body: some View { + let _ = Self._printChanges() // T6 TEMP #298, revert before merge let _ = AppLanguage.store.resolved ScrollView { VStack(alignment: .leading, spacing: Theme.Spacing.xl) { @@ -40,6 +41,7 @@ struct FrozenSettingsPage: View { } .padding(Theme.Spacing.xxl) } + .accessibilityIdentifier("settings-page") .scrollPosition(id: $settingsScrollTarget, anchor: .center) .background(Theme.windowBackground) .onAppear { diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift index aa6345a..138824c 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift @@ -28,25 +28,24 @@ extension ScreenshotE2ETests { walks += 1 attachDeviceFile("device-ax-walk-\(walks).txt") } - func timed(_ label: String, _ query: () -> Bool) { - let began = Date() - let found = query() - notes.append("\(label): found=\(found) in \(String(format: "%.1f", Date().timeIntervalSince(began))) s") - } app.launchEnvironment["ASKKEY_E2E_SCENARIO"] = "screenshots-devices" app.launchEnvironment["ASKKEY_E2E_LANGUAGE"] = language + // #298 bisection (temporary): English moves the sheet off the row; Chinese uses plain + // `.disabled`, no reset branch and no task on appear. + app.launchEnvironment["ASKKEY_T6_VARIANT"] = language == "en" ? "outer-sheet" : "plain" app.launch() addTeardownBlock { [app] in app?.terminate() } // a hung query must not also hang the teardown addTeardownBlock { // runs first: the App's log up to a failure try? fixture("write-log") self.attachDeviceFile("device-log.txt") + self.attachDeviceFile("device-stdout.txt") self.attachDeviceDiagnostics(notes) } moveManagementAboveDock() click("unlock-management") click("sidebar-settings") let pair = app.buttons["settings-pair-device"].firstMatch - let page = app.windows["settings"].scrollViews.containing(.button, identifier: "settings-pair-device").firstMatch + let page = app.scrollViews["settings-page"].firstMatch let sheet = app.windows["settings"].sheets.firstMatch XCTAssertTrue(pair.waitForExistence(timeout: 8)) for _ in 0..<6 where !pair.isHittable { page.scroll(byDeltaX: 0, deltaY: -300) } @@ -83,9 +82,7 @@ extension ScreenshotE2ETests { try fixture(command, centre) notes.append("\(command) at \(centre): " + (sheet.waitForExistence(timeout: 3) ? "sheet" : "no sheet")) } - try fixture("write-log") - attachDeviceFile("device-log.txt") - attachDeviceFile("device-stderr.txt") + attachDeviceFile("device-stdout.txt") if !sheet.exists { notes.append("fixture start"); try fixture("start-pairing") } XCTAssertTrue(sheet.waitForExistence(timeout: 8), "No pairing sheet") attachDeviceDiagnostics(notes) @@ -101,47 +98,29 @@ extension ScreenshotE2ETests { click("devices-pairing-done") try fixture("phone-poll") try walkAccessibility() - // #298 diagnostics: the same text query in the Settings window, then in the whole App. + // #298: after the code field had focus, snapshots of the Settings window hang on a remote + // text-input element in CI, so these checks find the Devices texts by identifier. let lastReached = language == "en" ? "Last reached" : "\u{4E0A}\u{6B21}\u{8054}\u{7CFB}" - let settings = app.windows["settings"].firstMatch - func textQuery(_ text: String, in root: XCUIElement) -> XCUIElement { - root.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS %@ OR value CONTAINS %@", text, text)).firstMatch + func shows(_ identifier: String, _ text: String) -> Bool { + let element = app.staticTexts[identifier].firstMatch + return element.exists && (element.label.contains(text) || (element.value as? String)?.contains(text) == true) + } + func waitUntilShown(_ identifier: String, _ text: String) { + let shown = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in shows(identifier, text) }, object: nil) + XCTAssertEqual(XCTWaiter.wait(for: [shown], timeout: 8), .completed, + "Missing \(text): \(app.staticTexts[identifier].firstMatch.label)") } + attachDeviceFile("device-stdout.txt") + attachDeviceDiagnostics(notes) let rename = app.buttons["devices-rename"].firstMatch XCTAssertTrue(rename.waitForExistence(timeout: 8)) - try fixture("write-log") - attachDeviceFile("device-log.txt") - // #298 diagnostics: whether the Devices section takes clicks after a pairing. for _ in 0..<6 where !rename.isHittable { page.scroll(byDeltaX: 0, deltaY: -300) } - rename.click() - notes.append("rename after pairing: field=\(app.textFields["devices-rename-field"].waitForExistence(timeout: 3))") - app.typeKey(.escape, modifierFlags: []) - notes.append("pair after pairing: hittable=\(pair.isHittable) frame=\(pair.frame)") - if pair.isHittable { pair.click() } - if sheet.waitForExistence(timeout: 3) { - notes.append("pair after pairing: sheet") - click("devices-pairing-cancel") - notes.append("sheet closed=\(sheet.waitForNonExistence(timeout: 5))") - } else { - notes.append("pair after pairing: no sheet") - } - attachDeviceDiagnostics(notes) - let sidebar = app.buttons["sidebar-settings"].firstMatch - timed("sidebar text query after Done") { textQuery("Settings", in: sidebar).waitForExistence(timeout: 2) } - attachDeviceDiagnostics(notes) - timed("Settings window text query after Done") { textQuery(lastReached, in: settings).waitForExistence(timeout: 8) } - attachDeviceDiagnostics(notes) - let row = app.buttons["devices-rename"].firstMatch - for _ in 0..<6 where !row.isHittable { page.scroll(byDeltaX: 0, deltaY: -300) } - XCTAssertTrue(textQuery(lastReached, in: settings).waitForExistence(timeout: 8)) + waitUntilShown("devices-row-reachability", lastReached) captureSettingsWindow(name(0, "devices-list")) try fixture("account-signed-out") let signedOut = language == "en" ? "iCloud is signed out" : "iCloud \u{5DF2}\u{9000}\u{51FA}\u{767B}\u{5F55}" - XCTAssertTrue(textQuery(signedOut, in: settings).waitForExistence(timeout: 8)) + waitUntilShown("settings-devices-notice", signedOut) captureSettingsWindow(name(4, "devices-icloud-signed-out")) - // Last, as it may time out: the same text query over the whole App. - timed("App text query after pairing") { textQuery(signedOut, in: app).waitForExistence(timeout: 2) } - attachDeviceDiagnostics(notes) XCTAssertEqual(clickOpened.first, true, "Pair a Device…: \(notes)") } diff --git a/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift b/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift index c974d4d..a193003 100644 --- a/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift +++ b/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift @@ -17,6 +17,13 @@ enum E2EDeviceDiagnostics { guard FileManager.default.createFile(atPath: path, contents: nil), let handle = FileHandle(forWritingAtPath: path) else { return } dup2(handle.fileDescriptor, STDERR_FILENO) + // SwiftUI's `_printChanges` writes to standard output. + let outPath = control.appendingPathComponent("device-stdout.txt").path + guard FileManager.default.createFile(atPath: outPath, contents: nil), + let out = FileHandle(forWritingAtPath: outPath) else { return } + fflush(stdout) + dup2(out.fileDescriptor, STDOUT_FILENO) + setvbuf(stdout, nil, _IOLBF, 0) } /// Writes the App's own unified log since launch to `device-log.txt` and returns how many From f9fee03ed782b0d46edad8f10d760d9eb32f660a Mon Sep 17 00:00:00 2001 From: sudoHG Date: Wed, 11 Nov 2026 02:31:29 +0800 Subject: [PATCH 14/21] TEMP #298 diagnostics, revert before merge: Devices first in Settings, and Erase as a lower control --- .../Settings/DevicesSettingsSection.swift | 80 +++++++------------ .../Views/Settings/FrozenSettingsPage.swift | 4 +- .../ScreenshotE2ETests+Devices.swift | 17 +++- 3 files changed, 44 insertions(+), 57 deletions(-) diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift index 7cb7fc5..51d669b 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift @@ -29,22 +29,38 @@ struct DevicesSettingsSection: View { private struct DevicesSectionContent: View { @Bindable var model: DevicesSettingsModel - // T6 TEMP #298 diagnostics, revert before merge: which half of the differences from the - // Backup row this run leaves out. - private let variant = ProcessInfo.processInfo.environment["ASKKEY_T6_VARIANT"] ?? "" var body: some View { - let _ = Self._printChanges() + let _ = Self._printChanges() // T6 TEMP #298, revert before merge // 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 - let group = GroupedList(header: appLocalized("Devices")) { - if variant == "outer-sheet" { - pairingRow(notice) - } else { - pairingRow(notice) - .sheet(isPresented: sheetBinding) { DevicePairingSheet(model: model) } + 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() @@ -72,49 +88,7 @@ private struct DevicesSectionContent: View { .accessibilityIdentifier("settings-devices-issue") } } - if variant == "outer-sheet" { - group - .sheet(isPresented: sheetBinding) { DevicePairingSheet(model: model) } - .onAppear { Task { await model.sectionOpened() } } - } else if variant == "plain" { - group - } else { - group.onAppear { Task { await model.sectionOpened() } } - } - } - - private func pairingRow(_ notice: DevicesPresentation.Notice) -> some View { - DevicesSettingRow(title: appLocalized("Pairing"), message: notice.text, isWarning: notice.isWarning) { - if variant == "plain" { - Button(appLocalized("Pair a Device…"), action: pairDevice) - .buttonStyle(.secondaryAction) - .disabled(!model.canPair) - .accessibilityIdentifier("settings-pair-device") - } else 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. - private var sheetBinding: Binding { - Binding( - get: { model.isSheetPresented }, - set: { presented in - if !presented, model.coordinator.pairing?.isActive != true { Task { await model.closeSheet() } } - } - ) + .onAppear { Task { await model.sectionOpened() } } } private func pairDevice() { diff --git a/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift b/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift index 2686192..588791f 100644 --- a/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift +++ b/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift @@ -33,10 +33,12 @@ struct FrozenSettingsPage: View { ScrollView { VStack(alignment: .leading, spacing: Theme.Spacing.xl) { PageHeader(title: appLocalized("Settings")) + // T6 TEMP #298 diagnostics, revert before merge: the Devices section first. + if ProcessInfo.processInfo.environment["ASKKEY_T6_VARIANT"] == "devices-first" { DevicesSettingsSection() } agentAccessSection generalSection BackupSettingsSection() - DevicesSettingsSection() + if ProcessInfo.processInfo.environment["ASKKEY_T6_VARIANT"] != "devices-first" { DevicesSettingsSection() } dataSection } .padding(Theme.Spacing.xxl) diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift index 138824c..bbfb353 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift @@ -30,9 +30,8 @@ extension ScreenshotE2ETests { } app.launchEnvironment["ASKKEY_E2E_SCENARIO"] = "screenshots-devices" app.launchEnvironment["ASKKEY_E2E_LANGUAGE"] = language - // #298 bisection (temporary): English moves the sheet off the row; Chinese uses plain - // `.disabled`, no reset branch and no task on appear. - app.launchEnvironment["ASKKEY_T6_VARIANT"] = language == "en" ? "outer-sheet" : "plain" + // #298 (temporary): the Chinese run shows the Devices section first in Settings. + if language != "en" { app.launchEnvironment["ASKKEY_T6_VARIANT"] = "devices-first" } app.launch() addTeardownBlock { [app] in app?.terminate() } // a hung query must not also hang the teardown addTeardownBlock { // runs first: the App's log up to a failure @@ -77,6 +76,18 @@ extension ScreenshotE2ETests { clickOpened.append(sheet.waitForExistence(timeout: 3)) notes.append(clickOpened.last == true ? "sheet" : "no sheet") } + // #298: whether a button below the Devices section, Erase…, takes its first click. + let erase = app.buttons.matching(NSPredicate(format: "label == %@", language == "en" ? "Erase\u{2026}" : "\u{62B9}\u{9664}\u{2026}")).firstMatch + if erase.waitForExistence(timeout: 3) { + for _ in 0..<6 where !erase.isHittable { page.scroll(byDeltaX: 0, deltaY: -300) } + notes.append("erase: enabled=\(erase.isEnabled) hittable=\(erase.isHittable) frame=\(erase.frame)") + erase.click() + let disabled = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isEnabled == false"), object: erase) + notes.append("erase after click: " + (XCTWaiter.wait(for: [disabled], timeout: 3) == .completed ? "opened its confirmation" : "nothing")) + } else { + notes.append("erase: not found") + } + for _ in 0..<6 where !pair.isHittable { page.scroll(byDeltaX: 0, deltaY: 300) } let centre = "\(pair.frame.midX) \(pair.frame.midY)" for command in ["inject-click"] where !sheet.exists { try fixture(command, centre) From b81ac28b0ca17f347a84eb49ede160f7ea5db891 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Wed, 11 Nov 2026 02:56:45 +0800 Subject: [PATCH 15/21] Bind the pairing sheet to view state that follows the session A sheet bound to a Binding(get:set:) over the model left Pair a Device... deaf to clicks on macOS 26 until the section redrew (#298). TEMP #298 diagnostics, revert before merge: the English E2E runs without the sheet; view changes are still printed. --- .../Settings/DevicesSettingsSection.swift | 54 ++++++++++++++----- .../Views/Settings/FrozenSettingsPage.swift | 4 +- .../ScreenshotE2ETests+Devices.swift | 29 +++++----- 3 files changed, 57 insertions(+), 30 deletions(-) diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift index 51d669b..0d1a71e 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift @@ -29,6 +29,10 @@ struct DevicesSettingsSection: View { private struct DevicesSectionContent: View { @Bindable var model: DevicesSettingsModel + /// Whether the pairing sheet shows: follows the session, see `presentation`. + @State private var sheetShown = false + // T6 TEMP #298 diagnostics, revert before merge. + private let withoutSheet = ProcessInfo.processInfo.environment["ASKKEY_T6_VARIANT"] == "no-sheet" var body: some View { let _ = Self._printChanges() // T6 TEMP #298, revert before merge @@ -50,18 +54,6 @@ private struct DevicesSectionContent: View { .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.")) { @@ -88,14 +80,52 @@ private struct DevicesSectionContent: View { .accessibilityIdentifier("settings-devices-issue") } } + .modifier(presentation) .onAppear { Task { await model.sectionOpened() } } } + /// The pairing sheet, bound to view state that follows the session. On + /// macOS 26 a sheet bound to a `Binding(get:set:)` over the model left + /// the row it was attached to deaf to clicks until the section redrew. + /// 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. + private var presentation: some ViewModifier { + PairingSheetPresentationModifier(model: model, shown: $sheetShown, enabled: !withoutSheet) + } + private func pairDevice() { Task { await model.pairDevice() } } } +private struct PairingSheetPresentationModifier: ViewModifier { + let model: DevicesSettingsModel + @Binding var shown: Bool + let enabled: Bool + + func body(content: Content) -> some View { + if enabled { + content + .sheet(isPresented: $shown) { DevicePairingSheet(model: model) } + .onChange(of: model.isSheetPresented) { _, presented in + if shown != presented { shown = presented } + } + .onChange(of: shown) { _, isShown in + guard !isShown, model.isSheetPresented else { return } + if model.coordinator.pairing?.isActive == true { + shown = true + } else { + Task { await model.closeSheet() } + } + } + } else { + content + } + } +} + /// 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 { diff --git a/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift b/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift index 588791f..2686192 100644 --- a/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift +++ b/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift @@ -33,12 +33,10 @@ struct FrozenSettingsPage: View { ScrollView { VStack(alignment: .leading, spacing: Theme.Spacing.xl) { PageHeader(title: appLocalized("Settings")) - // T6 TEMP #298 diagnostics, revert before merge: the Devices section first. - if ProcessInfo.processInfo.environment["ASKKEY_T6_VARIANT"] == "devices-first" { DevicesSettingsSection() } agentAccessSection generalSection BackupSettingsSection() - if ProcessInfo.processInfo.environment["ASKKEY_T6_VARIANT"] != "devices-first" { DevicesSettingsSection() } + DevicesSettingsSection() dataSection } .padding(Theme.Spacing.xxl) diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift index bbfb353..ec9ff66 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift @@ -30,8 +30,8 @@ extension ScreenshotE2ETests { } app.launchEnvironment["ASKKEY_E2E_SCENARIO"] = "screenshots-devices" app.launchEnvironment["ASKKEY_E2E_LANGUAGE"] = language - // #298 (temporary): the Chinese run shows the Devices section first in Settings. - if language != "en" { app.launchEnvironment["ASKKEY_T6_VARIANT"] = "devices-first" } + // #298 (temporary): the English run has no pairing sheet; its click is seen in the session. + if language == "en" { app.launchEnvironment["ASKKEY_T6_VARIANT"] = "no-sheet" } app.launch() addTeardownBlock { [app] in app?.terminate() } // a hung query must not also hang the teardown addTeardownBlock { // runs first: the App's log up to a failure @@ -68,26 +68,25 @@ extension ScreenshotE2ETests { ("click", { pair.click() }), ("hover, then press", { pair.hover(); pair.press(forDuration: 0.2) }) ] + func sessionsStarted() -> Int { + let record = (try? String(contentsOf: runDirectory.appendingPathComponent("device-transitions.json"), encoding: .utf8)) ?? "" + return record.components(separatedBy: "change: pairing=preparing").count - 1 + } var clickOpened: [Bool] = [] for (label, perform) in clicks where !sheet.exists { try fixture("snapshot") notes.append("\(label): enabled=\(pair.isEnabled) hittable=\(pair.isHittable) frame=\(pair.frame)") + let before = sessionsStarted() perform() clickOpened.append(sheet.waitForExistence(timeout: 3)) - notes.append(clickOpened.last == true ? "sheet" : "no sheet") - } - // #298: whether a button below the Devices section, Erase…, takes its first click. - let erase = app.buttons.matching(NSPredicate(format: "label == %@", language == "en" ? "Erase\u{2026}" : "\u{62B9}\u{9664}\u{2026}")).firstMatch - if erase.waitForExistence(timeout: 3) { - for _ in 0..<6 where !erase.isHittable { page.scroll(byDeltaX: 0, deltaY: -300) } - notes.append("erase: enabled=\(erase.isEnabled) hittable=\(erase.isHittable) frame=\(erase.frame)") - erase.click() - let disabled = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isEnabled == false"), object: erase) - notes.append("erase after click: " + (XCTWaiter.wait(for: [disabled], timeout: 3) == .completed ? "opened its confirmation" : "nothing")) - } else { - notes.append("erase: not found") + notes.append((clickOpened.last == true ? "sheet" : "no sheet") + ", sessions started \(sessionsStarted() - before)") + if language == "en" { + // #298 (temporary): without a sheet, the click shows in the session alone. + attachDeviceDiagnostics(notes) + XCTAssertGreaterThan(sessionsStarted(), before, "Pair a Device… without a sheet: \(notes)") + return + } } - for _ in 0..<6 where !pair.isHittable { page.scroll(byDeltaX: 0, deltaY: 300) } let centre = "\(pair.frame.midX) \(pair.frame.midY)" for command in ["inject-click"] where !sheet.exists { try fixture(command, centre) From 60aa955eede630fbd252654446fc8da804f1dc1d Mon Sep 17 00:00:00 2001 From: sudoHG Date: Wed, 11 Nov 2026 16:47:49 +0800 Subject: [PATCH 16/21] Pass the Devices model in from a holder that outlives Settings The Devices section kept an optional observable model in @State, created in its initializer. On macOS 26, Pair a Device... then ignored clicks until the section redrew (#298). The model now lives as long as the App's device coordinator and is passed in, as the Backup section receives the vault; a section that appears again closes a rename or confirmation left open. The Devices E2E clicks Pair a Device... itself and has no fixture path that starts a session, so the macOS 26 runner catches the regression. It finds the Devices texts by identifier: after the code field had focus, whole-window snapshots hung on the text cursor's remote element on the runner. This removes the temporary #298 diagnostics from 0f547e7, f9fee03 and b81ac28 and the diagnostic fixture commands. --- .../AskKeyAppKit/VaultViewModel+Devices.swift | 14 +- .../Settings/BackupSettingsSection.swift | 1 - .../Views/Settings/DevicePairingSheet.swift | 1 - .../Views/Settings/DevicesSettingsModel.swift | 5 + .../Settings/DevicesSettingsSection.swift | 64 ++---- .../Views/Settings/FrozenSettingsPage.swift | 1 - .../DevicesSettingsModelTests.swift | 17 +- .../ScreenshotE2ETests+Devices.swift | 162 ---------------- Tests/AskKeyE2ETests/ScreenshotE2ETests.swift | 78 +++++++- .../E2EDeviceDiagnostics.swift | 183 ------------------ .../AskKeyTestSupport/E2EDeviceScenario.swift | 47 +---- 11 files changed, 126 insertions(+), 447 deletions(-) delete mode 100644 Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift delete mode 100644 Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift diff --git a/Sources/AskKeyAppKit/VaultViewModel+Devices.swift b/Sources/AskKeyAppKit/VaultViewModel+Devices.swift index 16f592d..973e108 100644 --- a/Sources/AskKeyAppKit/VaultViewModel+Devices.swift +++ b/Sources/AskKeyAppKit/VaultViewModel+Devices.swift @@ -2,9 +2,17 @@ import Foundation extension VaultViewModel { /// The model behind Settings → Devices (ADR 0013 → Mac App), over the - /// App's one device coordinator. Nil in processes without device + /// 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 func makeDevicesSettingsModel() -> DevicesSettingsModel? { - DeviceServicesHost.coordinator.map(DevicesSettingsModel.init(coordinator:)) + 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/BackupSettingsSection.swift b/Sources/AskKeyAppKit/Views/Settings/BackupSettingsSection.swift index 103a9ae..ef1e06f 100644 --- a/Sources/AskKeyAppKit/Views/Settings/BackupSettingsSection.swift +++ b/Sources/AskKeyAppKit/Views/Settings/BackupSettingsSection.swift @@ -9,7 +9,6 @@ struct BackupSettingsSection: View { @State private var savedFileName: String? var body: some View { - let _ = Self._printChanges() // T6 TEMP #298, revert before merge // A separate view keeps its body across the parent's redraw, so it // subscribes to the language itself; otherwise a switch leaves this // section in the previous language. diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift b/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift index f94b7f1..844008a 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicePairingSheet.swift @@ -11,7 +11,6 @@ struct DevicePairingSheet: View { @FocusState private var codeFocused: Bool var body: some View { - let _ = Self._printChanges() // T6 TEMP #298, revert before merge let _ = AppLanguage.store.resolved let sheet = model.sheet ?? shown Group { diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift index 4f39d13..6242a24 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift @@ -77,7 +77,12 @@ final class DevicesSettingsModel { } } + /// 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() } diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift index 0d1a71e..5a4bbfa 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift @@ -5,19 +5,20 @@ import SwiftUI /// devices with **Rename**, **Test Delivery** and **Remove**, and why pairing /// or reaching them is unavailable. Builds without CloudKit show nothing. struct DevicesSettingsSection: View { - @State private var model: DevicesSettingsModel? + /// 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.makeDevicesSettingsModel()) + self.init(model: VaultViewModel.devicesSettingsModel) } init(model: DevicesSettingsModel?) { - _model = State(initialValue: model) + self.model = model } var body: some View { - let _ = Self._printChanges() // T6 TEMP #298, revert before merge // A separate view keeps its body across the parent's redraw, so it // subscribes to the language itself. let _ = AppLanguage.store.resolved @@ -29,13 +30,8 @@ struct DevicesSettingsSection: View { private struct DevicesSectionContent: View { @Bindable var model: DevicesSettingsModel - /// Whether the pairing sheet shows: follows the session, see `presentation`. - @State private var sheetShown = false - // T6 TEMP #298 diagnostics, revert before merge. - private let withoutSheet = ProcessInfo.processInfo.environment["ASKKEY_T6_VARIANT"] == "no-sheet" var body: some View { - let _ = Self._printChanges() // T6 TEMP #298, revert before merge // 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 @@ -54,6 +50,18 @@ private struct DevicesSectionContent: View { .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.")) { @@ -80,52 +88,14 @@ private struct DevicesSectionContent: View { .accessibilityIdentifier("settings-devices-issue") } } - .modifier(presentation) .onAppear { Task { await model.sectionOpened() } } } - /// The pairing sheet, bound to view state that follows the session. On - /// macOS 26 a sheet bound to a `Binding(get:set:)` over the model left - /// the row it was attached to deaf to clicks until the section redrew. - /// 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. - private var presentation: some ViewModifier { - PairingSheetPresentationModifier(model: model, shown: $sheetShown, enabled: !withoutSheet) - } - private func pairDevice() { Task { await model.pairDevice() } } } -private struct PairingSheetPresentationModifier: ViewModifier { - let model: DevicesSettingsModel - @Binding var shown: Bool - let enabled: Bool - - func body(content: Content) -> some View { - if enabled { - content - .sheet(isPresented: $shown) { DevicePairingSheet(model: model) } - .onChange(of: model.isSheetPresented) { _, presented in - if shown != presented { shown = presented } - } - .onChange(of: shown) { _, isShown in - guard !isShown, model.isSheetPresented else { return } - if model.coordinator.pairing?.isActive == true { - shown = true - } else { - Task { await model.closeSheet() } - } - } - } else { - content - } - } -} - /// 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 { diff --git a/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift b/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift index 2686192..42034fa 100644 --- a/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift +++ b/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift @@ -28,7 +28,6 @@ struct FrozenSettingsPage: View { } var body: some View { - let _ = Self._printChanges() // T6 TEMP #298, revert before merge let _ = AppLanguage.store.resolved ScrollView { VStack(alignment: .leading, spacing: Theme.Spacing.xl) { diff --git a/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift b/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift index 164354d..c3d0134 100644 --- a/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift +++ b/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift @@ -40,7 +40,7 @@ final class DevicesSettingsModelTests: AskKeyAppTestCase { )) XCTAssertFalse(DevicesSettingsModel(coordinator: coordinator).isSectionVisible) // This process has no CloudKit entitlement, so whatever it installed is not configured. - XCTAssertFalse(VaultViewModel.makeDevicesSettingsModel()?.isSectionVisible ?? false) + XCTAssertFalse(VaultViewModel.devicesSettingsModel?.isSectionVisible ?? false) XCTAssertTrue(model.isSectionVisible, "a configured build shows the section") } @@ -223,6 +223,21 @@ final class DevicesSettingsModelTests: AskKeyAppTestCase { 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") diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift deleted file mode 100644 index ec9ff66..0000000 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests+Devices.swift +++ /dev/null @@ -1,162 +0,0 @@ -import XCTest - -/// Settings → Devices screenshots (#288): 30–34 in English and 35–39 in Chinese. -extension ScreenshotE2ETests { - func testDevicesScreensInEnglish() throws { - try captureDevicesScreens(language: "en", tag: "en", firstNumber: 30) - } - - func testDevicesScreensInChinese() throws { - try captureDevicesScreens(language: "zh-Hans", tag: "zh", firstNumber: 35) - } - - /// The list with one paired iPhone, the pairing sheet's QR code, waiting and code entry steps, - /// and iCloud signed out, on the E2E App's fake device services. The fixture plays the iPhone, - /// including the person typing its code, which never leaves the App. - 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, _ argument: String = "requested") throws { - try? FileManager.default.removeItem(at: runDirectory.appendingPathComponent("device-\(command).json")) - try "\(argument)\n".write(to: runDirectory.appendingPathComponent("command-\(command).txt"), atomically: true, encoding: .utf8) - let report = try waitForEvidence("device-\(command).json") - XCTAssertEqual(report["ok"] as? Bool, true, "\(command): \(report)") - } - var notes: [String] = [] - var walks = 0 - func walkAccessibility() throws { - try fixture("ax-walk") - walks += 1 - attachDeviceFile("device-ax-walk-\(walks).txt") - } - app.launchEnvironment["ASKKEY_E2E_SCENARIO"] = "screenshots-devices" - app.launchEnvironment["ASKKEY_E2E_LANGUAGE"] = language - // #298 (temporary): the English run has no pairing sheet; its click is seen in the session. - if language == "en" { app.launchEnvironment["ASKKEY_T6_VARIANT"] = "no-sheet" } - app.launch() - addTeardownBlock { [app] in app?.terminate() } // a hung query must not also hang the teardown - addTeardownBlock { // runs first: the App's log up to a failure - try? fixture("write-log") - self.attachDeviceFile("device-log.txt") - self.attachDeviceFile("device-stdout.txt") - self.attachDeviceDiagnostics(notes) - } - moveManagementAboveDock() - click("unlock-management") - click("sidebar-settings") - let pair = app.buttons["settings-pair-device"].firstMatch - let page = app.scrollViews["settings-page"].firstMatch - let sheet = app.windows["settings"].sheets.firstMatch - XCTAssertTrue(pair.waitForExistence(timeout: 8)) - for _ in 0..<6 where !pair.isHittable { page.scroll(byDeltaX: 0, deltaY: -300) } - try walkAccessibility() - // #298 diagnostics: whether another button in the same page opens its sheet in this - // scenario, then XCTest's clicks on Pair a Device… and a click the App posts to itself, - // while the fixture records the mouse events the App receives. The fixture starts the - // session if none of them opened the sheet. - let export = app.buttons["settings-export-backup"].firstMatch - notes.append("export: hittable=\(export.isHittable) frame=\(export.frame)") - export.click() - if sheet.waitForExistence(timeout: 5) { - notes.append("export sheet") - click("backup-export-cancel") - notes.append("export sheet closed=\(sheet.waitForNonExistence(timeout: 5))") - } else { - notes.append("export: no sheet, alerts=\(app.alerts.count)") - if app.alerts.firstMatch.exists { app.alerts.firstMatch.buttons.firstMatch.click() } - } - let clicks: [(String, () -> Void)] = [ - ("click", { pair.click() }), - ("hover, then press", { pair.hover(); pair.press(forDuration: 0.2) }) - ] - func sessionsStarted() -> Int { - let record = (try? String(contentsOf: runDirectory.appendingPathComponent("device-transitions.json"), encoding: .utf8)) ?? "" - return record.components(separatedBy: "change: pairing=preparing").count - 1 - } - var clickOpened: [Bool] = [] - for (label, perform) in clicks where !sheet.exists { - try fixture("snapshot") - notes.append("\(label): enabled=\(pair.isEnabled) hittable=\(pair.isHittable) frame=\(pair.frame)") - let before = sessionsStarted() - perform() - clickOpened.append(sheet.waitForExistence(timeout: 3)) - notes.append((clickOpened.last == true ? "sheet" : "no sheet") + ", sessions started \(sessionsStarted() - before)") - if language == "en" { - // #298 (temporary): without a sheet, the click shows in the session alone. - attachDeviceDiagnostics(notes) - XCTAssertGreaterThan(sessionsStarted(), before, "Pair a Device… without a sheet: \(notes)") - return - } - } - let centre = "\(pair.frame.midX) \(pair.frame.midY)" - for command in ["inject-click"] where !sheet.exists { - try fixture(command, centre) - notes.append("\(command) at \(centre): " + (sheet.waitForExistence(timeout: 3) ? "sheet" : "no sheet")) - } - attachDeviceFile("device-stdout.txt") - if !sheet.exists { notes.append("fixture start"); try fixture("start-pairing") } - XCTAssertTrue(sheet.waitForExistence(timeout: 8), "No pairing sheet") - attachDeviceDiagnostics(notes) - 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") - try walkAccessibility() - // #298: after the code field had focus, snapshots of the Settings window hang on a remote - // text-input element in CI, so these checks find the Devices texts by identifier. - let lastReached = language == "en" ? "Last reached" : "\u{4E0A}\u{6B21}\u{8054}\u{7CFB}" - func shows(_ identifier: String, _ text: String) -> Bool { - let element = app.staticTexts[identifier].firstMatch - return element.exists && (element.label.contains(text) || (element.value as? String)?.contains(text) == true) - } - func waitUntilShown(_ identifier: String, _ text: String) { - let shown = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in shows(identifier, text) }, object: nil) - XCTAssertEqual(XCTWaiter.wait(for: [shown], timeout: 8), .completed, - "Missing \(text): \(app.staticTexts[identifier].firstMatch.label)") - } - attachDeviceFile("device-stdout.txt") - attachDeviceDiagnostics(notes) - 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", lastReached) - captureSettingsWindow(name(0, "devices-list")) - try fixture("account-signed-out") - let signedOut = language == "en" ? "iCloud is signed out" : "iCloud \u{5DF2}\u{9000}\u{51FA}\u{767B}\u{5F55}" - waitUntilShown("settings-devices-notice", signedOut) - captureSettingsWindow(name(4, "devices-icloud-signed-out")) - XCTAssertEqual(clickOpened.first, true, "Pair a Device…: \(notes)") - } - - /// The fixture's record (the coordinator's states, the App's mouse events and accessibility - /// walks) and the notes so far, attached at once so they survive a later query timeout. - private func attachDeviceDiagnostics(_ notes: [String]) { - let record = (try? String(contentsOf: runDirectory.appendingPathComponent("device-transitions.json"), encoding: .utf8)) ?? "none" - let attachment = XCTAttachment(string: notes.joined(separator: "\n") + "\n\n" + record) - attachment.name = "device-diagnostics" - attachment.lifetime = .keepAlways - add(attachment) - } - - /// The Settings window alone: the App-wide window list can include system text-input windows. - private func captureSettingsWindow(_ name: String) { - let attachment = XCTAttachment(screenshot: app.windows["settings"].firstMatch.screenshot()) - attachment.name = name + ".png" - attachment.lifetime = .keepAlways - add(attachment) - } - - private func attachDeviceFile(_ file: String) { - let text = (try? String(contentsOf: runDirectory.appendingPathComponent(file), encoding: .utf8)) ?? "missing \(file)" - let attachment = XCTAttachment(string: text) - attachment.name = file - attachment.lifetime = .keepAlways - add(attachment) - } -} diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift index 759024c..ca4cdf9 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift @@ -343,6 +343,68 @@ 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") + XCTAssertTrue(app.windows["settings"].sheets.firstMatch.waitForExistence(timeout: 8), "Pair a Device… opened no sheet") + 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,6 +420,14 @@ final class ScreenshotE2ETests: E2EBaseCase { XCTAssertTrue(app.buttons["settings-agent-access"].waitForExistence(timeout: 8)) } + /// 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 @@ -426,7 +496,7 @@ final class ScreenshotE2ETests: E2EBaseCase { } /// The sheet over the management window, which shows the backup flows. - func captureSheet(_ name: String) { + private func captureSheet(_ name: String) { let sheet = app.windows["settings"].sheets.firstMatch XCTAssertTrue(sheet.waitForExistence(timeout: 8), "Missing sheet for \(name)") let attachment = XCTAttachment(screenshot: sheet.screenshot()) @@ -435,7 +505,7 @@ final class ScreenshotE2ETests: E2EBaseCase { add(attachment) } - func capture(_ name: String, approval: Bool = false) { + private func capture(_ name: String, approval: Bool = false) { // AppKit's floating approval NSPanel is exposed as a dialog, while the // management scene is a window. Query both before taking a window crop. let windows = app.windows.allElementsBoundByIndex + app.dialogs.allElementsBoundByIndex @@ -457,7 +527,7 @@ final class ScreenshotE2ETests: E2EBaseCase { add(attachment) } - func moveManagementAboveDock() { + private func moveManagementAboveDock() { let window = app.windows["settings"].firstMatch XCTAssertTrue(window.waitForExistence(timeout: 8)) let origin = window.frame.origin @@ -476,7 +546,7 @@ final class ScreenshotE2ETests: E2EBaseCase { button.click() } - func waitForText(_ text: String, containing: Bool = false) { + private func waitForText(_ text: String, containing: Bool = false) { let predicate = NSPredicate(format: containing ? "label CONTAINS %@ OR value CONTAINS %@" : "label == %@ OR value == %@", text, text) XCTAssertTrue(app.descendants(matching: .any).matching(predicate).firstMatch diff --git a/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift b/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift deleted file mode 100644 index a193003..0000000 --- a/Tests/AskKeyTestSupport/E2EDeviceDiagnostics.swift +++ /dev/null @@ -1,183 +0,0 @@ -import AppKit -import OSLog - -/// Diagnostics for #298, in the E2E App only: XCTest's click on Pair a Device… opened no sheet, -/// and full-tree queries timed out after a pairing. They record which window and views receive -/// the App's mouse events, and the App's own accessibility tree. Nothing they record carries a -/// pairing code or QR payload: the Mac never shows its code, and the QR image's label names it only. -@MainActor -enum E2EDeviceDiagnostics { - private static var monitor: Any? - private static var walks = 0 - - /// Sends the App's standard error to `device-stderr.txt`, where SwiftUI and AttributeGraph - /// print some of their runtime warnings. - static func captureStandardError(control: URL) { - let path = control.appendingPathComponent("device-stderr.txt").path - guard FileManager.default.createFile(atPath: path, contents: nil), - let handle = FileHandle(forWritingAtPath: path) else { return } - dup2(handle.fileDescriptor, STDERR_FILENO) - // SwiftUI's `_printChanges` writes to standard output. - let outPath = control.appendingPathComponent("device-stdout.txt").path - guard FileManager.default.createFile(atPath: outPath, contents: nil), - let out = FileHandle(forWritingAtPath: outPath) else { return } - fflush(stdout) - dup2(out.fileDescriptor, STDOUT_FILENO) - setvbuf(stdout, nil, _IOLBF, 0) - } - - /// Writes the App's own unified log since launch to `device-log.txt` and returns how many - /// entries came from SwiftUI, AppKit and AttributeGraph, or mention a cycle. - static func writeLog(control: URL) -> String { - do { - let store = try OSLogStore(scope: .currentProcessIdentifier) - var lines: [String] = [] - var frameworks = 0, cycles = 0 - // XCTest's in-App automation logs every element it reads; those lines are left out. - let noise = ["faulted in", "Fetching AX attributes", "Got attributes", "Computed elementType", "Automation type mismatch"] - for case let entry as OSLogEntryLog in try store.getEntries() { - let subsystem = entry.subsystem - let message = entry.composedMessage - if noise.contains(where: message.contains) { continue } - let framework = subsystem.hasPrefix("com.apple.SwiftUI") || subsystem.hasPrefix("com.apple.AppKit") - || subsystem.lowercased().contains("attributegraph") || message.contains("AttributeGraph") - if framework { frameworks += 1 } - if message.lowercased().contains("cycle") { cycles += 1 } - lines.append("\(entry.date.timeIntervalSince1970) [\(subsystem)/\(entry.category)] \(message)") - } - try lines.suffix(20_000).joined(separator: "\n").write(to: control.appendingPathComponent("device-log.txt"), atomically: true, encoding: .utf8) - return "log entries=\(lines.count) framework=\(frameworks) cycle=\(cycles)" - } catch { - return "log unavailable: \(error)" - } - } - - /// Reports each left mouse down and up the App receives. - static func recordMouseEvents(_ report: @escaping @MainActor (String) -> Void) { - guard monitor == nil else { return } - monitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .leftMouseUp]) { event in - report(describe(event)) - return event - } - } - - /// Walks every window's accessibility tree in the App's process and writes the listing, - /// with each visible window's AppKit views and their frames, to `device-ax-walk-.txt`. - /// Returns a summary: the element count, any cycle, the slowest elements, the first - /// responder, and the AppKit views under the Pair a Device… button. - static func walkAccessibility(control: URL) -> String { - walks += 1 - var lines: [String] = [], slow: [String] = [] - var seen = Set() - var count = 0, cycles = 0, widest = 0 - var pairFrame: NSRect? - let start = Date() - func walk(_ object: Any, depth: Int) { - guard count < 30_000, Date().timeIntervalSince(start) < 10 else { return } - let node = object as AnyObject - let pad = String(repeating: " ", count: depth) - guard seen.insert(ObjectIdentifier(node)).inserted else { - cycles += 1 - lines.append(pad + "CYCLE \(type(of: node))") - return - } - count += 1 - guard let element = node as? NSObject else { return lines.append(pad + "not an object: \(type(of: node))") } - let began = Date() - let role = element.accessibilityAttributeValue(.role) as? String ?? "-" - let identifier = element.accessibilityAttributeValue(.identifier) as? String ?? "" - let label = element.accessibilityAttributeValue(NSAccessibility.Attribute(rawValue: "AXDescription")) as? String - ?? element.accessibilityAttributeValue(.title) as? String ?? "" - let value = element.accessibilityAttributeValue(.value).map { String(describing: $0) } ?? "" - let frame = (element as? NSAccessibilityElementProtocol)?.accessibilityFrame() ?? .zero - let focused = element.accessibilityAttributeValue(.focused) as? Bool ?? false - let children = element.accessibilityAttributeValue(.children) as? [Any] ?? [] - let elapsed = Date().timeIntervalSince(began) - widest = max(widest, children.count) - if identifier == "settings-pair-device" { pairFrame = frame } - let line = "\(pad)\(type(of: node)) role=\(role) id=\(identifier) label=\(label.prefix(60)) value=\(value.prefix(60)) " - + "frame=\(frame) focused=\(focused) children=\(children.count) took=\(String(format: "%.3f", elapsed))" - lines.append(line) - if elapsed > 0.05 { slow.append(line) } - for child in children { walk(child, depth: depth + 1) } - } - for window in NSApp.windows { walk(window, depth: 0) } - let summary = "accessibility walk \(walks): elements=\(count) cycles=\(cycles) widest=\(widest) slow=\(slow.count) " - + "took=\(String(format: "%.2f", Date().timeIntervalSince(start))) key=\(NSApp.keyWindow.map(name) ?? "none") " - + "firstResponder=\(NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "none") " - + "windows=\(NSApp.windows.map { "\(name($0)) visible=\($0.isVisible) sheet=\($0.attachedSheet != nil)" }) " - + "pair=\(pairFrame.map(viewsUnder) ?? "absent")" - var views: [String] = [] - func dump(_ view: NSView, depth: Int) { - guard views.count < 2_000 else { return } - let frame = view.window.map { _ in view.convert(view.bounds, to: nil) } ?? view.frame - views.append("\(String(repeating: " ", count: depth))\(type(of: view)) window-frame=\(frame) hidden=\(view.isHidden) " - + "alpha=\(view.alphaValue) layer=\(view.wantsLayer)") - for subview in view.subviews { dump(subview, depth: depth + 1) } - } - for window in NSApp.windows where window.isVisible { - views.append("VIEWS \(name(window)) frame=\(window.frame)") - if let frameView = window.contentView?.superview { dump(frameView, depth: 1) } - } - let listing = ([summary] + slow.map { "SLOW " + $0 } + views + lines).joined(separator: "\n") - try? listing.write(to: control.appendingPathComponent("device-ax-walk-\(walks).txt"), atomically: true, encoding: .utf8) - return summary - } - - /// Posts a left mouse down and up to the App's own event queue at a point in XCTest's screen - /// coordinates (top-left origin), as AppKit would queue a hardware click. Both are queued - /// together, so a view that tracks the mouse after the down finds the up. - static func postClick(atRunnerPoint point: NSPoint) async -> String { - guard let screen = NSScreen.screens.first else { return "no screen" } - let screenPoint = NSPoint(x: point.x, y: screen.frame.maxY - point.y) - guard let window = NSApp.windows.first(where: { $0.isVisible && $0.level == .normal && $0.frame.contains(screenPoint) }) else { - return "no window at \(screenPoint)" - } - let location = window.convertPoint(fromScreen: screenPoint) - let now = ProcessInfo.processInfo.systemUptime - let events = [(NSEvent.EventType.leftMouseDown, now, Float(1)), (.leftMouseUp, now + 0.1, Float(0))].compactMap { type, time, pressure in - NSEvent.mouseEvent(with: type, location: location, modifierFlags: [], timestamp: time, windowNumber: window.windowNumber, - context: nil, eventNumber: 0, clickCount: 1, pressure: pressure) - } - events.forEach { NSApp.postEvent($0, atStart: false) } - try? await Task.sleep(for: .milliseconds(500)) - return "posted \(events.count) events at \(location) in \(name(window))" - } - - /// The AppKit views under the centre of a screen rectangle, in each visible window containing it. - private static func viewsUnder(_ screenFrame: NSRect) -> String { - let point = NSPoint(x: screenFrame.midX, y: screenFrame.midY) - let hits = NSApp.windows.filter { $0.isVisible && $0.frame.contains(point) }.map { window in - "\(name(window)) frame=\(screenFrame) hit=\(chain(window, at: window.convertPoint(fromScreen: point)))" - } - return hits.isEmpty ? "no window at \(point)" : hits.joined(separator: "; ") - } - - private static func describe(_ event: NSEvent) -> String { - let window = event.window - let down = event.type == .leftMouseDown - var text = "\(down ? "mouse down" : "mouse up") window=\(window.map(name) ?? "none") key=\(window?.isKeyWindow ?? false) " - + "active=\(NSApp.isActive) point=\(event.locationInWindow) screen=\(NSEvent.mouseLocation) " - + "hit=\(window.map { chain($0, at: event.locationInWindow) } ?? "none") " - + "firstResponder=\(window?.firstResponder.map { String(describing: type(of: $0)) } ?? "none") " - + "sheet=\(window?.attachedSheet != nil) clicks=\(event.clickCount)" - if down { - text += " visible=\(NSApp.windows.filter(\.isVisible).map { "\(name($0)) level=\($0.level.rawValue) frame=\($0.frame)" })" - } - return text - } - - private static func chain(_ window: NSWindow, at point: NSPoint) -> String { - var views: [String] = [] - var view = window.contentView?.superview?.hitTest(point) - while let current = view, views.count < 8 { - views.append(String(describing: type(of: current))) - view = current.superview - } - return "\(views)" - } - - private static func name(_ window: NSWindow) -> String { - "\(type(of: window))[\(window.identifier?.rawValue ?? "-")]" - } -} diff --git a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift index f61ffe7..852e960 100644 --- a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift +++ b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift @@ -1,4 +1,3 @@ -import AppKit import AskKeyAppKit import AskKeyDevices import Foundation @@ -14,7 +13,7 @@ import Observation enum E2EDeviceScenario { static let scenarioPrefix = "screenshots-devices" static let phoneName = "Synthetic iPhone" - static let commands = ["snapshot", "start-pairing", "phone-scan", "phone-reveal", "phone-complete", "phone-poll", "account-signed-out", "ax-walk", "resign-focus", "inject-click", "write-log"] + static let commands = ["phone-scan", "phone-reveal", "phone-complete", "phone-poll", "account-signed-out"] private static var backend: FakeDeviceServicesBackend? private static var phone: SimulatedPairingPhone? @@ -38,22 +37,16 @@ enum E2EDeviceScenario { /// and writes `device-.json` with the outcome. static func start(control: URL) { self.control = control - E2EDeviceDiagnostics.captureStandardError(control: control) recordPairingTransitions(control: control) - E2EDeviceDiagnostics.recordMouseEvents { event in - transitions.append("\(Date().timeIntervalSince1970) \(event)") - writeTransitions(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 } - let argument = (try? String(contentsOf: file, encoding: .utf8)) ?? "" try? FileManager.default.removeItem(at: file) var report: [String: Any] = ["command": command] do { - try await run(command, argument: argument) + try await run(command) report["ok"] = true } catch { report["ok"] = false @@ -112,20 +105,11 @@ enum E2EDeviceScenario { } } - private static func run(_ command: String, argument: String) async throws { + private static func run(_ command: String) async throws { guard let coordinator = DeviceServicesHost.coordinator, let phone, let backend else { throw E2EDeviceScenarioError.notInstalled } switch command { - case "snapshot": - // What Pair a Device… checks before it starts, at the moment the runner clicks. - transitions.append("\(Date().timeIntervalSince1970) snapshot before a click: secureEnclave=\(backend.isSecureEnclaveAvailable) " - + "pairing=\(coordinator.pairing.map(describe) ?? "none") availability=\(coordinator.availability) " - + "account=\(coordinator.account) identity=\(coordinator.hasIdentity) devices=\(coordinator.devices.count)") - if let control { writeTransitions(control: control) } - case "start-pairing": - // Diagnostic only: what Pair a Device… does, for a run in which the click did not start it. - await coordinator.startPairing() case "phone-scan": guard case .showingQRCode(let text, _) = coordinator.pairing else { throw E2EDeviceScenarioError.noQRCode } try await phone.scan(text, name: phoneName) @@ -153,31 +137,6 @@ enum E2EDeviceScenario { if case .reached = coordinator.devices.first?.reachability { return true } return false } - case "ax-walk": - // Diagnostic only (#298): the App's own accessibility tree. - guard let control else { throw E2EDeviceScenarioError.notInstalled } - transitions.append("\(Date().timeIntervalSince1970) \(E2EDeviceDiagnostics.walkAccessibility(control: control))") - writeTransitions(control: control) - case "resign-focus": - // Diagnostic only (#298): the key window drops its first responder. - let window = NSApp.keyWindow - transitions.append("\(Date().timeIntervalSince1970) resign focus: key=\(window.map { String(describing: type(of: $0)) } ?? "none") " - + "firstResponder=\(window?.firstResponder.map { String(describing: type(of: $0)) } ?? "none") " - + "resigned=\(window?.makeFirstResponder(nil) ?? false)") - if let control { writeTransitions(control: control) } - case "inject-click": - // Diagnostic only (#298): a click the App posts to itself at a point in the runner's - // screen coordinates, beside XCTest's synthesized one. - let values = argument.split(whereSeparator: \.isWhitespace).compactMap { Double($0) } - guard values.count == 2 else { throw E2EDeviceScenarioError.unknownCommand } - let outcome = await E2EDeviceDiagnostics.postClick(atRunnerPoint: NSPoint(x: values[0], y: values[1])) - transitions.append("\(Date().timeIntervalSince1970) posted click: \(outcome)") - if let control { writeTransitions(control: control) } - case "write-log": - // Diagnostic only (#298): the App's own log and standard error so far. - guard let control else { throw E2EDeviceScenarioError.notInstalled } - transitions.append("\(Date().timeIntervalSince1970) \(E2EDeviceDiagnostics.writeLog(control: control))") - writeTransitions(control: control) case "account-signed-out": backend.accountState = .noAccount backend.signalAccountChange() From e3105060b4902cf9eb6a726768e2e24ea22c7269 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Wed, 11 Nov 2026 16:49:07 +0800 Subject: [PATCH 17/21] Stop promising that a removed device removes this Mac ADR 0013 lets the unpair notice be lost or expire, so the removal confirmation now says only that Ask Key tries to tell the device. --- Sources/AskKeyAppKit/Resources/Localizable.xcstrings | 8 ++++---- .../AskKeyAppKit/Views/Settings/DevicesPresentation.swift | 2 +- Tests/AskKeyAppTests/DevicesSettingsModelTests.swift | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings index 4fcd2af..3cd96ac 100644 --- a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings +++ b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings @@ -16968,12 +16968,12 @@ "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, which then removes this Mac the next time it connects. If it still lists this Mac later, remove this Mac on that device too.": { + "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, which then removes this Mac the next time it connects. If it still lists this Mac later, 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, which then removes this Mac the next time it connects. If it still lists this Mac later, remove this Mac on that device too.!!]" } } + "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…": { diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesPresentation.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesPresentation.swift index c43183e..085ce87 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicesPresentation.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesPresentation.swift @@ -133,7 +133,7 @@ enum DevicesPresentation { } static func removalWarning(_ name: String) -> String { - appLocalizedFormat("This Mac stops trusting “%@” at once. Ask Key tries to tell the device, which then removes this Mac the next time it connects. If it still lists this Mac later, remove this Mac on that device too.", name) + 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 { diff --git a/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift b/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift index c3d0134..5768e05 100644 --- a/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift +++ b/Tests/AskKeyAppTests/DevicesSettingsModelTests.swift @@ -211,7 +211,7 @@ final class DevicesSettingsModelTests: AskKeyAppTestCase { 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, which then removes this Mac the next time it connects. If it still lists this Mac later, remove this Mac on that device too.") + "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") From 756230130ccce401060c2e2e74a149c735c63b19 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Wed, 11 Nov 2026 17:41:04 +0800 Subject: [PATCH 18/21] Rebuild the Devices controls once after the section appears On macOS 26, Pair a Device... ignored clicks until the Devices section redrew; passing the model in from a stable holder did not change that. The cause is unknown (#298). Clicks worked in every run after a redraw, so the section rebuilds its controls once, after it has loaded. Nothing redraws repeatedly, and the controls look the same before and after. --- .../Settings/DevicesSettingsSection.swift | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift index 5a4bbfa..eb30f08 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift @@ -30,6 +30,9 @@ struct DevicesSettingsSection: View { private struct DevicesSectionContent: View { @Bindable var model: DevicesSettingsModel + /// Changes once after the section appeared, which rebuilds its controls; + /// see `onAppear`. + @State private var controlsGeneration = 0 var body: some View { // Each separate view subscribes to the language itself; the parent's @@ -43,11 +46,13 @@ private struct DevicesSectionContent: View { .buttonStyle(.irreversibleAction) .dimmedAndDisabled(model.confirmingReset) .accessibilityIdentifier("settings-reset-device-identity") + .id(controlsGeneration) } else { Button(appLocalized("Pair a Device…"), action: pairDevice) .buttonStyle(.secondaryAction) .dimmedAndDisabled(!model.canPair) .accessibilityIdentifier("settings-pair-device") + .id(controlsGeneration) } } // The session decides whether the sheet shows. A live session ends @@ -75,6 +80,7 @@ private struct DevicesSectionContent: View { ForEach(model.coordinator.devices) { row in GroupedListSeparator() DeviceRow(model: model, row: row) + .id(controlsGeneration) } if let issue = model.coordinator.lastIssue { GroupedListSeparator() @@ -88,7 +94,18 @@ private struct DevicesSectionContent: View { .accessibilityIdentifier("settings-devices-issue") } } - .onAppear { Task { await model.sectionOpened() } } + .onAppear { + Task { + await model.sectionOpened() + // macOS 26 workaround (#298), cause unknown: when nothing the + // section shows changes as it appears, as on a Mac that has + // not paired yet, its buttons ignore clicks until it redraws. + // Rebuilding the controls once, after the section has loaded, + // restores them. One rebuild per appearance; nothing redraws + // repeatedly, and the controls look the same before and after. + controlsGeneration += 1 + } + } } private func pairDevice() { From 4927ae1a912e2abb4262ac3970938188c408c32a Mon Sep 17 00:00:00 2001 From: sudoHG Date: Wed, 11 Nov 2026 18:29:25 +0800 Subject: [PATCH 19/21] Revert "Rebuild the Devices controls once after the section appears" This reverts commit 756230130ccce401060c2e2e74a149c735c63b19. --- .../Settings/DevicesSettingsSection.swift | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift index eb30f08..5a4bbfa 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift @@ -30,9 +30,6 @@ struct DevicesSettingsSection: View { private struct DevicesSectionContent: View { @Bindable var model: DevicesSettingsModel - /// Changes once after the section appeared, which rebuilds its controls; - /// see `onAppear`. - @State private var controlsGeneration = 0 var body: some View { // Each separate view subscribes to the language itself; the parent's @@ -46,13 +43,11 @@ private struct DevicesSectionContent: View { .buttonStyle(.irreversibleAction) .dimmedAndDisabled(model.confirmingReset) .accessibilityIdentifier("settings-reset-device-identity") - .id(controlsGeneration) } else { Button(appLocalized("Pair a Device…"), action: pairDevice) .buttonStyle(.secondaryAction) .dimmedAndDisabled(!model.canPair) .accessibilityIdentifier("settings-pair-device") - .id(controlsGeneration) } } // The session decides whether the sheet shows. A live session ends @@ -80,7 +75,6 @@ private struct DevicesSectionContent: View { ForEach(model.coordinator.devices) { row in GroupedListSeparator() DeviceRow(model: model, row: row) - .id(controlsGeneration) } if let issue = model.coordinator.lastIssue { GroupedListSeparator() @@ -94,18 +88,7 @@ private struct DevicesSectionContent: View { .accessibilityIdentifier("settings-devices-issue") } } - .onAppear { - Task { - await model.sectionOpened() - // macOS 26 workaround (#298), cause unknown: when nothing the - // section shows changes as it appears, as on a Mac that has - // not paired yet, its buttons ignore clicks until it redraws. - // Rebuilding the controls once, after the section has loaded, - // restores them. One rebuild per appearance; nothing redraws - // repeatedly, and the controls look the same before and after. - controlsGeneration += 1 - } - } + .onAppear { Task { await model.sectionOpened() } } } private func pairDevice() { From 3bbcf63338e0738c46c0107fddeedede06e80d68 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Wed, 11 Nov 2026 18:31:20 +0800 Subject: [PATCH 20/21] TEMP #298 diagnostics, revert before merge: trace what Pair a Device... does on a click The button action, the model's canPair and its inputs, and each return of the coordinator's startPairing are recorded in the E2E App; the screenshot test prints the trace after its click. --- .../Devices/DeviceCoordinator+Pairing.swift | 44 ++++++++++++++++--- .../Views/Settings/DevicesSettingsModel.swift | 31 ++++++++++++- .../Settings/DevicesSettingsSection.swift | 1 + Tests/AskKeyE2ETests/ScreenshotE2ETests.swift | 6 ++- .../AskKeyTestSupport/E2EDeviceScenario.swift | 7 ++- 5 files changed, 80 insertions(+), 9 deletions(-) diff --git a/Sources/AskKeyAppKit/Devices/DeviceCoordinator+Pairing.swift b/Sources/AskKeyAppKit/Devices/DeviceCoordinator+Pairing.swift index 0522237..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) } diff --git a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift index 6242a24..85ef914 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsModel.swift @@ -87,9 +87,19 @@ final class DevicesSettingsModel { } func pairDevice() async { - guard canPair else { return } + // 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 @@ -234,3 +244,22 @@ final class DevicesSettingsModel { 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 index 5a4bbfa..8e2d869 100644 --- a/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift +++ b/Sources/AskKeyAppKit/Views/Settings/DevicesSettingsSection.swift @@ -92,6 +92,7 @@ private struct DevicesSectionContent: View { } private func pairDevice() { + DevicesPairingTrace.log("button action: Pair a Device…") // T6 TEMP #298, revert before merge Task { await model.pairDevice() } } } diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift index ca4cdf9..09a044d 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift @@ -383,7 +383,11 @@ final class ScreenshotE2ETests: E2EBaseCase { XCTAssertTrue(pair.waitForExistence(timeout: 8)) for _ in 0..<6 where !pair.isHittable { page.scroll(byDeltaX: 0, deltaY: -300) } click("settings-pair-device") - XCTAssertTrue(app.windows["settings"].sheets.firstMatch.waitForExistence(timeout: 8), "Pair a Device… opened no sheet") + 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-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") diff --git a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift index 852e960..64ed979 100644 --- a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift +++ b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift @@ -13,7 +13,7 @@ import Observation 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"] + 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? @@ -137,6 +137,11 @@ enum E2EDeviceScenario { 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-trace.json"), options: .atomic) case "account-signed-out": backend.accountState = .noAccount backend.signalAccountChange() From e4aa5dfd8f1ea1ded5c6029172d1911e82db39d3 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Wed, 11 Nov 2026 19:00:34 +0800 Subject: [PATCH 21/21] TEMP #298 diagnostics, revert before merge: keep the pairing trace apart from the fixture's report file --- Tests/AskKeyE2ETests/ScreenshotE2ETests.swift | 2 +- Tests/AskKeyTestSupport/E2EDeviceScenario.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift index 09a044d..bc33cc8 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift @@ -385,7 +385,7 @@ final class ScreenshotE2ETests: E2EBaseCase { 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-trace.json"), encoding: .utf8)) ?? "none" + 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)) diff --git a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift index 64ed979..55aa2f1 100644 --- a/Tests/AskKeyTestSupport/E2EDeviceScenario.swift +++ b/Tests/AskKeyTestSupport/E2EDeviceScenario.swift @@ -141,7 +141,7 @@ enum E2EDeviceScenario { // 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-trace.json"), options: .atomic) + try data.write(to: control.appendingPathComponent("device-pairing-trace.json"), options: .atomic) case "account-signed-out": backend.accountState = .noAccount backend.signalAccountChange()