From 1be056144ddfb94487f97744a765b288a8a33743 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:13:07 +0800 Subject: [PATCH 01/39] docs: design Arc authorization diagnostics --- .../2026-08-03-arc-diagnostics-design.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-03-arc-diagnostics-design.md diff --git a/docs/superpowers/specs/2026-08-03-arc-diagnostics-design.md b/docs/superpowers/specs/2026-08-03-arc-diagnostics-design.md new file mode 100644 index 0000000..76935ee --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-arc-diagnostics-design.md @@ -0,0 +1,114 @@ +# Arc 授权窗口诊断设计 + +## 目标 + +为密码桥增加一次性、只读的浏览器授权窗口诊断能力,明确 Arc 失败发生在应用规则、CoreGraphics 窗口预筛选、Accessibility 授权上下文还是验证码输入框识别阶段。 + +诊断功能不改变现有扫描、验证码读取或自动填入逻辑,也不尝试修复 Arc 兼容性。它只收集定位根因所需的最小元数据。 + +## 用户体验 + +菜单增加“诊断当前授权窗口”按钮。用户在 Arc 的 iCloud 密码授权界面保持可见时点击该按钮,应用执行一次诊断并在菜单内显示摘要。 + +诊断完成后显示: + +- 结论:通过、应用未运行、未发现候选窗口、AX 上下文不匹配或未识别输入框。 +- 最近一次诊断时间。 +- “复制诊断报告”按钮。 + +诊断运行期间不激活浏览器、不聚焦输入框、不发送键盘事件,也不读取 Apple“密码”中的验证码。 + +## 诊断数据流 + +### 1. 应用规则层 + +记录当前规则模式、目标应用的显示名、bundle ID、PID、运行状态和 activation policy。诊断范围与现有应用规则一致,确保报告能区分“Arc 未运行”与“Arc 被规则排除”。 + +### 2. CoreGraphics 窗口层 + +枚举目标应用当前可见的普通窗口,记录: + +- PID 和窗口编号; +- 窗口尺寸; +- 脱敏标题; +- 是否通过现有 `isICloudPasswordWindowTitle` 判断。 + +即使窗口未通过标题规则,也要保留一条诊断记录,以确认 Arc 实际暴露的窗口标题。 + +### 3. Accessibility 层 + +对目标应用的 AX 窗口执行现有深度和节点数限制下的只读遍历,记录: + +- AX 窗口数量和脱敏标题; +- 是否观察到 `moz-extension://` 或 `chrome-extension://`; +- 是否观察到 `/page_popup.html`; +- 是否命中 iCloud、自动填充和验证码语义; +- `AXTextField`、`AXTextArea`、`AXSecureTextField` 及其他可编辑角色的数量; +- 当前稳定 popup 签名和授权上下文判断结果。 + +诊断器复用现有解析规则,但不复用只返回成功目标的接口,以便保留每道失败原因。 + +### 4. 结论层 + +按最早失败边界给出稳定的机器可读代码和中文说明: + +- `application_not_running` +- `application_rejected_by_policy` +- `no_visible_windows` +- `window_title_mismatch` +- `ax_windows_unavailable` +- `authorization_context_mismatch` +- `input_roles_unrecognized` +- `target_recognized` + +报告同时列出后续各层观察结果,避免单一错误文案掩盖多个结构差异。 + +## 组件设计 + +新增 `BrowserDiagnostics.swift`: + +- `BrowserDiagnosticReport`:完整诊断结果和纯文本渲染。 +- `ApplicationDiagnostic`、`WindowDiagnostic`、`AccessibilityDiagnostic`:各边界的结构化结果。 +- `BrowserDiagnostics.run(policy:)`:只读执行一次诊断。 +- `DiagnosticRedactor`:负责标题、URL和数字脱敏。 + +`BridgeModel` 仅负责触发诊断、持有最近一次内存报告和复制操作状态。`BridgeApp` 只展示摘要和按钮,不包含判断逻辑。 + +## 隐私与脱敏 + +- 不调用 `PasswordCodeReader`,不读取或输出验证码。 +- 不记录 Apple“密码”窗口正文。 +- 不将完整 AX 文本写入报告。 +- 来自窗口标题、URL或 AX 文本的连续或分隔六位数字统一替换为 ``;这些文本中的其他连续数字也按通用数字标记脱敏。 +- PID、窗口编号、尺寸、计数和时间戳作为有标签的系统诊断字段保留,不与窗口或 AX 文本拼接。 +- 扩展 URL 仅保留 scheme 和末尾路径,例如 `chrome-extension:///page_popup.html`。 +- 窗口标题只保留识别所需的短文本,并在输出前执行数字与 URL 脱敏。 +- 报告仅存在内存和系统剪贴板;只有用户点击“复制诊断报告”时才进入剪贴板。 +- 不写文件、不使用 `print`、`NSLog` 或持久化日志。 + +## 错误处理 + +权限不足、窗口在诊断过程中关闭、AX 属性不可读都转换为报告中的观察项,不令应用崩溃。诊断按钮可重复运行,新的结果原子替换旧报告。 + +诊断期间按钮显示进行中并避免并发执行;它不阻止既有自动扫描,但所有诊断操作必须保持只读。 + +## 测试 + +新增单元测试覆盖: + +- Arc bundle ID 能按白名单进入诊断范围; +- 每个失败边界生成正确结论代码; +- iCloud 标题规则通过与失败的报告差异; +- Chromium 扩展 URL 被识别且扩展 ID 被脱敏; +- 六位码、分隔六位码和一般数字不会出现在复制报告中; +- 单字段、六字段和未知可编辑角色得到不同诊断结果; +- 报告不包含原始 AX 全文。 + +现有自动填入测试保持不变。构建验收运行 `swift test --disable-sandbox`,并在 Arc 授权窗口上人工确认诊断报告能够指出具体失败边界。 + +## 非目标 + +- 本阶段不修改授权窗口匹配规则。 +- 不加入 Arc 专用标题、URL或输入框兼容逻辑。 +- 不新增磁盘日志、遥测或网络上传。 +- 不自动收集或提交诊断报告。 From 99d6f9731b8f30b4bc6fc2f9a4992e845d6c7f4b Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:20:00 +0800 Subject: [PATCH 02/39] docs: plan Arc authorization diagnostics --- .../plans/2026-08-03-arc-diagnostics.md | 913 ++++++++++++++++++ 1 file changed, 913 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-arc-diagnostics.md diff --git a/docs/superpowers/plans/2026-08-03-arc-diagnostics.md b/docs/superpowers/plans/2026-08-03-arc-diagnostics.md new file mode 100644 index 0000000..5ae1aa8 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-arc-diagnostics.md @@ -0,0 +1,913 @@ +# Arc Authorization Diagnostics Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a privacy-preserving, one-shot in-app diagnostic report that identifies the exact recognition boundary preventing Arc's iCloud Passwords authorization window from being autofilled. + +**Architecture:** Add a focused `BrowserDiagnostics.swift` unit containing immutable observations, deterministic conclusion evaluation, redaction, plain-text rendering, and a live macOS collector. `BridgeModel` owns only the latest in-memory report and actions; `BridgeApp` renders the summary and copy button. Existing autofill matching and code-reading behavior remains unchanged. + +**Tech Stack:** Swift 5.10, SwiftUI, AppKit, ApplicationServices Accessibility API, CoreGraphics window APIs, XCTest, Swift Package Manager. + +--- + +## File map + +- Create `Sources/ApplePasswordBridge/BrowserDiagnostics.swift`: diagnostic data model, conclusion evaluator, redactor, report renderer, and read-only live collector. +- Create `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift`: deterministic evaluator, privacy, rendering, Arc allowlist, URL, and input-role tests. +- Modify `Sources/ApplePasswordBridge/BridgeModel.swift`: published diagnostic state, trigger method, and explicit copy action. +- Modify `Sources/ApplePasswordBridge/BridgeApp.swift`: diagnostic controls and most-recent result presentation. +- Keep `Sources/ApplePasswordBridge/FirefoxAutofill.swift`, `PasswordCodeReader.swift`, and `CodeParser.swift` behavior unchanged. + +### Task 1: Define diagnostic observations and conclusion evaluation + +**Files:** +- Create: `Sources/ApplePasswordBridge/BrowserDiagnostics.swift` +- Create: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Write failing evaluator tests** + +Create the test file with a helper and one test per recognition boundary: + +```swift +import XCTest +@testable import ApplePasswordBridge + +final class BrowserDiagnosticsTests: XCTestCase { + private func application( + running: Bool = true, + permitted: Bool = true, + windows: [DiagnosticWindow] = [], + accessibilityWindows: [DiagnosticAccessibilityWindow] = [] + ) -> DiagnosticApplication { + DiagnosticApplication( + displayName: "Arc", + bundleIdentifier: "company.thebrowser.Browser", + processIdentifier: running ? 42 : nil, + activationPolicy: running ? "regular" : nil, + permitted: permitted, + windows: windows, + accessibilityWindows: accessibilityWindows + ) + } + + private func recognizedApplication(roleCounts: [String: Int]) -> DiagnosticApplication { + let window = DiagnosticWindow( + number: 7, + width: 600, + height: 500, + title: "iCloud Passwords", + titleMatches: true + ) + let axWindow = DiagnosticAccessibilityWindow( + title: "iCloud Passwords", + extensionURLSummary: "chrome-extension:///page_popup.html", + hasExtensionURL: true, + hasPopupPath: true, + hasICloudIdentity: true, + hasAutofillTerms: true, + hasVerificationCodeTerms: true, + roleCounts: roleCounts, + stablePopupSignature: true, + authorizationContextMatches: true + ) + return application(windows: [window], accessibilityWindows: [axWindow]) + } + + func testConclusionReportsApplicationNotRunning() { + XCTAssertEqual( + BrowserDiagnosticConclusion.evaluate([application(running: false)]), + .applicationNotRunning + ) + } + + func testConclusionReportsPolicyRejection() { + XCTAssertEqual( + BrowserDiagnosticConclusion.evaluate([application(permitted: false)]), + .applicationRejectedByPolicy + ) + } + + func testConclusionReportsNoVisibleWindows() { + XCTAssertEqual( + BrowserDiagnosticConclusion.evaluate([application()]), + .noVisibleWindows + ) + } + + func testConclusionReportsTitleMismatchBeforeAXMismatch() { + let window = DiagnosticWindow(number: 7, width: 600, height: 500, title: "Arc", titleMatches: false) + XCTAssertEqual( + BrowserDiagnosticConclusion.evaluate([application(windows: [window])]), + .windowTitleMismatch + ) + } + + func testConclusionReportsUnavailableAccessibilityWindows() { + let window = DiagnosticWindow(number: 7, width: 600, height: 500, title: "iCloud Passwords", titleMatches: true) + XCTAssertEqual( + BrowserDiagnosticConclusion.evaluate([application(windows: [window])]), + .accessibilityWindowsUnavailable + ) + } + + func testConclusionReportsAuthorizationContextMismatch() { + let window = DiagnosticWindow(number: 7, width: 600, height: 500, title: "iCloud Passwords", titleMatches: true) + let axWindow = DiagnosticAccessibilityWindow( + title: "Arc", + extensionURLSummary: nil, + hasExtensionURL: false, + hasPopupPath: false, + hasICloudIdentity: false, + hasAutofillTerms: false, + hasVerificationCodeTerms: false, + roleCounts: [:], + stablePopupSignature: false, + authorizationContextMatches: false + ) + XCTAssertEqual( + BrowserDiagnosticConclusion.evaluate([application(windows: [window], accessibilityWindows: [axWindow])]), + .authorizationContextMismatch + ) + } + + func testConclusionReportsUnrecognizedInputRoles() { + let report = recognizedApplication(roleCounts: ["AXGroup": 6]) + XCTAssertEqual(BrowserDiagnosticConclusion.evaluate([report]), .inputRolesUnrecognized) + } + + func testConclusionReportsRecognizedTarget() { + let report = recognizedApplication(roleCounts: ["AXTextField": 6]) + XCTAssertEqual(BrowserDiagnosticConclusion.evaluate([report]), .targetRecognized) + } +} +``` + +Do not use live system APIs in these tests. + +- [ ] **Step 2: Run the focused tests and verify they fail** + +Run: + +```bash +swift test --disable-sandbox --filter BrowserDiagnosticsTests +``` + +Expected: compilation fails because the diagnostic types do not exist. + +- [ ] **Step 3: Add the minimal immutable types and evaluator** + +Create `BrowserDiagnostics.swift` with these public-to-target internal definitions: + +```swift +import AppKit +import ApplicationServices +import CoreGraphics + +enum BrowserDiagnosticConclusion: String, Equatable { + case applicationNotRunning = "application_not_running" + case applicationRejectedByPolicy = "application_rejected_by_policy" + case noVisibleWindows = "no_visible_windows" + case windowTitleMismatch = "window_title_mismatch" + case accessibilityWindowsUnavailable = "ax_windows_unavailable" + case authorizationContextMismatch = "authorization_context_mismatch" + case inputRolesUnrecognized = "input_roles_unrecognized" + case targetRecognized = "target_recognized" + + static func evaluate(_ applications: [DiagnosticApplication]) -> Self { + guard applications.contains(where: { $0.processIdentifier != nil }) else { return .applicationNotRunning } + let permitted = applications.filter { $0.processIdentifier != nil && $0.permitted } + guard !permitted.isEmpty else { return .applicationRejectedByPolicy } + guard permitted.contains(where: { !$0.windows.isEmpty }) else { return .noVisibleWindows } + guard permitted.flatMap(\.windows).contains(where: \.titleMatches) else { return .windowTitleMismatch } + let axWindows = permitted.flatMap(\.accessibilityWindows) + guard !axWindows.isEmpty else { return .accessibilityWindowsUnavailable } + let matching = axWindows.filter { $0.stablePopupSignature || $0.authorizationContextMatches } + guard !matching.isEmpty else { return .authorizationContextMismatch } + guard matching.contains(where: { $0.supportedInputCount > 0 }) else { return .inputRolesUnrecognized } + return .targetRecognized + } +} + +struct DiagnosticWindow: Equatable { + let number: UInt32 + let width: Int + let height: Int + let title: String + let titleMatches: Bool +} + +struct DiagnosticAccessibilityWindow: Equatable { + let title: String + let extensionURLSummary: String? + let hasExtensionURL: Bool + let hasPopupPath: Bool + let hasICloudIdentity: Bool + let hasAutofillTerms: Bool + let hasVerificationCodeTerms: Bool + let roleCounts: [String: Int] + let stablePopupSignature: Bool + let authorizationContextMatches: Bool + + var supportedInputCount: Int { + [kAXTextFieldRole as String, kAXTextAreaRole as String, "AXSecureTextField"] + .reduce(0) { $0 + (roleCounts[$1] ?? 0) } + } +} + +struct DiagnosticApplication: Equatable { + let displayName: String + let bundleIdentifier: String + let processIdentifier: pid_t? + let activationPolicy: String? + let permitted: Bool + let windows: [DiagnosticWindow] + let accessibilityWindows: [DiagnosticAccessibilityWindow] +} +``` + +- [ ] **Step 4: Run the focused tests and verify they pass** + +Run `swift test --disable-sandbox --filter BrowserDiagnosticsTests`. + +Expected: all evaluator tests pass. + +- [ ] **Step 5: Commit the evaluator slice** + +```bash +git add Sources/ApplePasswordBridge/BrowserDiagnostics.swift Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: model browser diagnostic outcomes" +``` + +### Task 2: Add privacy redaction and deterministic report rendering + +**Files:** +- Modify: `Sources/ApplePasswordBridge/BrowserDiagnostics.swift` +- Modify: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Write failing redaction and rendering tests** + +Add tests that exercise the exact privacy boundary: + +```swift +func testRedactorRemovesVerificationCodesAndExtensionIdentifiers() { + let input = "Arc 123456 chrome-extension://abcdefghijklmnop/page_popup.html?popupWindow=42" + let output = DiagnosticRedactor.redactText(input) + XCTAssertFalse(output.contains("123456")) + XCTAssertFalse(output.contains("abcdefghijklmnop")) + XCTAssertTrue(output.contains("")) + XCTAssertTrue(output.contains("chrome-extension:///page_popup.html")) +} + +func testRedactorRemovesSeparatedVerificationCode() { + XCTAssertEqual( + DiagnosticRedactor.redactText("验证码 1 2 3 4 5 6"), + "验证码 " + ) +} + +func testPlainTextReportContainsLabeledSystemFieldsWithoutRawAXText() { + let report = BrowserDiagnosticReport( + generatedAt: Date(timeIntervalSince1970: 0), + ruleMode: .allowlist, + applications: [recognizedApplication(roleCounts: ["AXTextField": 6])] + ) + XCTAssertTrue(report.plainText.contains("company.thebrowser.Browser")) + XCTAssertTrue(report.plainText.contains("PID: 42")) + XCTAssertTrue(report.plainText.contains("target_recognized")) + XCTAssertFalse(report.plainText.contains("123456")) +} +``` + +- [ ] **Step 2: Run tests and verify redaction APIs are missing** + +Run `swift test --disable-sandbox --filter BrowserDiagnosticsTests`. + +Expected: compilation fails for `DiagnosticRedactor` and `BrowserDiagnosticReport`. + +- [ ] **Step 3: Implement redaction and report rendering** + +Add `DiagnosticRedactor` with precompiled `NSRegularExpression` instances. Apply the six-digit expression before generic numeric redaction, redact extension hosts, strip query strings, and cap redacted titles at 160 characters: + +```swift +enum DiagnosticRedactor { + private static let sixDigits = try! NSRegularExpression( + pattern: #"(? String { + var output = replace( + sixDigits, + in: value, + with: "" + ) + output = redactExtensionURLs(in: output) + output = replace(otherNumbers, in: output, with: "") + return output + } + + static func redactTitle(_ value: String?) -> String { + let redacted = redactText(value ?? "") + return String(redacted.prefix(160)) + } + + static func extensionURLSummary(from value: String) -> String? { + let range = NSRange(value.startIndex..., in: value) + guard let match = extensionURL.firstMatch(in: value, range: range), + let swiftRange = Range(match.range, in: value) else { return nil } + return summarizeExtensionURL(String(value[swiftRange])) + } + + private static func redactExtensionURLs(in value: String) -> String { + let range = NSRange(value.startIndex..., in: value) + let matches = extensionURL.matches(in: value, range: range).reversed() + var output = value + for match in matches { + guard let swiftRange = Range(match.range, in: output) else { continue } + output.replaceSubrange(swiftRange, with: summarizeExtensionURL(String(output[swiftRange]))) + } + return output + } + + private static func summarizeExtensionURL(_ value: String) -> String { + let withoutQuery = value.split(separator: "?", maxSplits: 1).first.map(String.init) ?? value + guard let schemeRange = withoutQuery.range(of: "://"), + let slash = withoutQuery[schemeRange.upperBound...].firstIndex(of: "/") else { + return "" + } + return String(withoutQuery[.." + + String(withoutQuery[slash...]) + } + + private static func replace( + _ expression: NSRegularExpression, + in value: String, + with replacement: String + ) -> String { + expression.stringByReplacingMatches( + in: value, + range: NSRange(value.startIndex..., in: value), + withTemplate: replacement + ) + } +} + +struct BrowserDiagnosticReport: Equatable { + let generatedAt: Date + let ruleMode: ApplicationRuleMode + let applications: [DiagnosticApplication] + + var conclusion: BrowserDiagnosticConclusion { + .evaluate(applications) + } + + var summary: String { conclusion.localizedSummary } + + var plainText: String { + var lines = [ + "ApplePasswordBridge browser diagnostics", + "Generated: \(generatedAt.ISO8601Format())", + "Rule mode: \(ruleMode.rawValue)", + "Conclusion: \(conclusion.rawValue) — \(summary)" + ] + for application in applications { + lines.append("Application: \(application.displayName) [\(application.bundleIdentifier)]") + lines.append(" PID: \(application.processIdentifier.map(String.init) ?? "not-running")") + lines.append(" Activation policy: \(application.activationPolicy ?? "unavailable")") + lines.append(" Permitted: \(application.permitted)") + for window in application.windows { + lines.append(" CG window #\(window.number): \(window.width)x\(window.height)") + lines.append(" Title: \(window.title)") + lines.append(" iCloud title match: \(window.titleMatches)") + } + for window in application.accessibilityWindows { + lines.append(" AX window: \(window.title)") + lines.append(" Extension URL: \(window.extensionURLSummary ?? "not-observed")") + lines.append(" Flags: extension=\(window.hasExtensionURL), popup=\(window.hasPopupPath), icloud=\(window.hasICloudIdentity), autofill=\(window.hasAutofillTerms), code=\(window.hasVerificationCodeTerms)") + let roles = window.roleCounts.keys.sorted().map { "\($0)=\(window.roleCounts[$0] ?? 0)" } + lines.append(" Roles: \(roles.joined(separator: ", "))") + lines.append(" Existing match: popup=\(window.stablePopupSignature), authorization=\(window.authorizationContextMatches)") + } + } + return lines.joined(separator: "\n") + } +} + +protocol BrowserDiagnosticsRunning { + func run( + policy: ApplicationRulePolicy, + configuredApplications: [TargetApplication], + now: Date + ) -> BrowserDiagnosticReport +} +``` + +Add `localizedSummary` to `BrowserDiagnosticConclusion` with an exhaustive `switch` mapping each case to the Chinese labels in the approved design. Do not store or render raw AX text anywhere in these types. + +- [ ] **Step 4: Run focused tests and inspect privacy assertions** + +Run `swift test --disable-sandbox --filter BrowserDiagnosticsTests`. + +Expected: tests pass; the test output contains no supplied code or extension identifier. + +- [ ] **Step 5: Commit the privacy slice** + +```bash +git add Sources/ApplePasswordBridge/BrowserDiagnostics.swift Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: redact and render diagnostic reports" +``` + +### Task 3: Implement the read-only live macOS collector + +**Files:** +- Modify: `Sources/ApplePasswordBridge/BrowserDiagnostics.swift` +- Modify: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Write failing pure-observation tests for Arc and Chromium metadata** + +Add tests for the collector's pure helpers rather than mocking macOS frameworks: + +```swift +func testArcAllowlistEntryIsIncluded() { + let policy = ApplicationRulePolicy( + mode: .allowlist, + allowlist: ["company.thebrowser.Browser"], + denylist: [] + ) + XCTAssertTrue(policy.permits(bundleIdentifier: "company.thebrowser.Browser")) +} + +func testAXObservationRecognizesChromiumPopupWithoutKeepingRawText() { + let observation = BrowserDiagnostics.makeAccessibilityObservation( + title: "iCloud Passwords", + nodes: [ + DiagnosticNode(role: "AXStaticText", text: "chrome-extension://secret-id/page_popup.html"), + DiagnosticNode(role: "AXTextField", text: "") + ] + ) + XCTAssertTrue(observation.hasExtensionURL) + XCTAssertTrue(observation.hasPopupPath) + XCTAssertEqual(observation.roleCounts["AXTextField"], 1) + XCTAssertFalse(String(describing: observation).contains("secret-id")) +} +``` + +The resulting observation must retain flags and redacted summaries, never the supplied raw combined text. + +- [ ] **Step 2: Run focused tests and verify the helper is missing** + +Run `swift test --disable-sandbox --filter BrowserDiagnosticsTests`. + +Expected: compilation fails for `DiagnosticNode` or `makeAccessibilityObservation`. + +- [ ] **Step 3: Implement `BrowserDiagnostics` collection** + +First expose exact semantic helpers in `CodeParser.swift` and route the existing authorization check through them: + +```swift +static func hasAutofillTerms(_ text: String) -> Bool { + let value = text.lowercased() + return autofillTerms.contains(where: value.contains) +} + +static func hasVerificationCodeTerms(_ text: String) -> Bool { + let value = text.lowercased() + return codeTerms.contains(where: value.contains) +} + +static func isApplePasswordAuthorization(_ text: String) -> Bool { + hasAutofillTerms(text) && hasVerificationCodeTerms(text) +} +``` + +Then add the collector implementation: + +```swift +struct DiagnosticNode { + let role: String + let text: String +} + +final class BrowserDiagnostics: BrowserDiagnosticsRunning { + func run( + policy: ApplicationRulePolicy, + configuredApplications: [TargetApplication], + now: Date = Date() + ) -> BrowserDiagnosticReport { + let runningApplications = applicationsToInspect( + policy: policy, + configuredApplications: configuredApplications + ) + let windowInfo = CGWindowListCopyWindowInfo( + [.optionOnScreenOnly, .excludeDesktopElements], + kCGNullWindowID + ) as? [[String: Any]] ?? [] + + let observations = runningApplications.map { configured, running in + let pid = running?.processIdentifier + let windows = pid.map { processIdentifier in + windowInfo.compactMap { info -> DiagnosticWindow? in + guard (info[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value == processIdentifier, + let number = info[kCGWindowNumber as String] as? NSNumber else { return nil } + let rawBounds = info[kCGWindowBounds as String] as? [String: Any] + let bounds = rawBounds.flatMap { + CGRect(dictionaryRepresentation: $0 as CFDictionary) + } ?? .zero + let title = DiagnosticRedactor.redactTitle(info[kCGWindowName as String] as? String) + return DiagnosticWindow( + number: number.uint32Value, + width: Int(bounds.width), + height: Int(bounds.height), + title: title, + titleMatches: AuthorizationContext.isICloudPasswordWindowTitle(title) + ) + } + } ?? [] + + let accessibilityWindows: [DiagnosticAccessibilityWindow] + if let running { + let application = AXUIElementCreateApplication(running.processIdentifier) + AccessibilityTree.enableEnhancedUserInterface(application) + accessibilityWindows = AccessibilityTree.windows(of: application).map { window in + let nodes = AccessibilityTree.collect(from: window).map { + DiagnosticNode(role: $0.role, text: $0.text) + } + return Self.makeAccessibilityObservation( + title: AccessibilityTree.title(of: window), + nodes: nodes + ) + } + } else { + accessibilityWindows = [] + } + + return DiagnosticApplication( + displayName: configured.displayName, + bundleIdentifier: configured.bundleIdentifier, + processIdentifier: pid, + activationPolicy: running.map { activationPolicyName($0.activationPolicy) }, + permitted: policy.permits(bundleIdentifier: configured.bundleIdentifier), + windows: windows, + accessibilityWindows: accessibilityWindows + ) + } + + return BrowserDiagnosticReport( + generatedAt: now, + ruleMode: policy.mode, + applications: observations + ) + } + + static func makeAccessibilityObservation( + title: String?, + nodes: [DiagnosticNode] + ) -> DiagnosticAccessibilityWindow { + let combined = nodes.map(\.text).joined(separator: "\n") + let lowercased = combined.lowercased() + let roleCounts = Dictionary(grouping: nodes, by: \.role).mapValues(\.count) + return DiagnosticAccessibilityWindow( + title: DiagnosticRedactor.redactTitle(title), + extensionURLSummary: DiagnosticRedactor.extensionURLSummary(from: combined), + hasExtensionURL: lowercased.contains("moz-extension://") + || lowercased.contains("chrome-extension://"), + hasPopupPath: lowercased.contains("/page_popup.html"), + hasICloudIdentity: lowercased.contains("icloud 密码") + || lowercased.contains("icloud passwords") + || AuthorizationContext.isICloudPasswordWindowTitle(title), + hasAutofillTerms: AuthorizationContext.hasAutofillTerms(combined), + hasVerificationCodeTerms: AuthorizationContext.hasVerificationCodeTerms(combined), + roleCounts: roleCounts, + stablePopupSignature: AuthorizationContext.isBrowserExtensionPopup(title: title, text: combined), + authorizationContextMatches: AuthorizationContext.isBrowserExtensionAuthorization(title: title, text: combined) + ) + } + + private func applicationsToInspect( + policy: ApplicationRulePolicy, + configuredApplications: [TargetApplication] + ) -> [(TargetApplication, NSRunningApplication?)] { + switch policy.mode { + case .allowlist: + return configuredApplications.map { configured in + let running = NSRunningApplication.runningApplications( + withBundleIdentifier: configured.bundleIdentifier + ).first + return (configured, running) + } + case .denylist: + return NSWorkspace.shared.runningApplications + .filter { + $0.activationPolicy == .regular + && $0.bundleIdentifier.map(policy.permits(bundleIdentifier:)) == true + } + .prefix(12) + .compactMap { running in + guard let bundleIdentifier = running.bundleIdentifier else { return nil } + let configured = TargetApplication( + bundleIdentifier: bundleIdentifier, + displayName: running.localizedName ?? bundleIdentifier + ) + return (configured, running) + } + } + } + + private func activationPolicyName(_ policy: NSApplication.ActivationPolicy) -> String { + switch policy { + case .regular: return "regular" + case .accessory: return "accessory" + case .prohibited: return "prohibited" + @unknown default: return "unknown" + } + } +} +``` + +Review the implementation mechanically to confirm it never calls `AccessibilityTree.focus`, `raise`, `NSRunningApplication.activate`, `PasswordCodeReader`, or any `CGEvent` API. + +- [ ] **Step 4: Run the full unit suite** + +Run `swift test --disable-sandbox`. + +Expected: all existing and diagnostic tests pass; no behavior changes to autofill tests. + +- [ ] **Step 5: Commit the collector slice** + +```bash +git add Sources/ApplePasswordBridge/BrowserDiagnostics.swift Sources/ApplePasswordBridge/CodeParser.swift Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: collect browser recognition diagnostics" +``` + +Only stage `CodeParser.swift` if shared internal semantic helpers were required. + +### Task 4: Integrate diagnostic state and copy action into `BridgeModel` + +**Files:** +- Modify: `Sources/ApplePasswordBridge/BridgeModel.swift:23-52,116-161,335-341` +- Modify: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Add a failing state-transition test around an injected runner** + +Reuse the protocol added with report rendering: + +```swift +protocol BrowserDiagnosticsRunning { + func run( + policy: ApplicationRulePolicy, + configuredApplications: [TargetApplication], + now: Date + ) -> BrowserDiagnosticReport +} +``` + +Add a fake runner test proving a diagnostic request stores the report and resets `isDiagnosing`: + +```swift +private final class FakeDiagnosticsRunner: BrowserDiagnosticsRunning { + let report: BrowserDiagnosticReport + + init(report: BrowserDiagnosticReport) { + self.report = report + } + + func run( + policy: ApplicationRulePolicy, + configuredApplications: [TargetApplication], + now: Date + ) -> BrowserDiagnosticReport { + report + } +} + +@MainActor +func testModelStoresCompletedDiagnosticReport() { + let report = BrowserDiagnosticReport( + generatedAt: Date(timeIntervalSince1970: 0), + ruleMode: .allowlist, + applications: [recognizedApplication(roleCounts: ["AXTextField": 6])] + ) + let model = BridgeModel( + browserDiagnostics: FakeDiagnosticsRunner(report: report), + startAutomatically: false + ) + XCTAssertNil(model.diagnosticReport) + model.runDiagnostics() + XCTAssertEqual(model.diagnosticReport, report) + XCTAssertFalse(model.isDiagnosing) +} +``` + +- [ ] **Step 2: Run the focused test and verify initializer/state APIs are missing** + +Run `swift test --disable-sandbox --filter BrowserDiagnosticsTests`. + +Expected: compilation fails for the injected initializer and diagnostic properties. + +- [ ] **Step 3: Add model state and actions** + +Add these members: + +```swift +@Published private(set) var diagnosticReport: BrowserDiagnosticReport? +@Published private(set) var isDiagnosing = false + +private let browserDiagnostics: BrowserDiagnosticsRunning + +init( + browserDiagnostics: BrowserDiagnosticsRunning = BrowserDiagnostics(), + startAutomatically: Bool = true +) { + self.browserDiagnostics = browserDiagnostics + UserDefaults.standard.register(defaults: [ + Keys.monitoring: true, + Keys.automaticFill: true, + Keys.ocrFallback: true, + Keys.fillSpeed: FillSpeed.reliable.rawValue, + Keys.applicationRuleMode: ApplicationRuleMode.allowlist.rawValue + ]) + monitoringEnabled = UserDefaults.standard.bool(forKey: Keys.monitoring) + automaticFillEnabled = UserDefaults.standard.bool(forKey: Keys.automaticFill) + ocrFallbackEnabled = UserDefaults.standard.bool(forKey: Keys.ocrFallback) + fillSpeed = FillSpeed( + rawValue: UserDefaults.standard.string(forKey: Keys.fillSpeed) ?? "" + ) ?? .reliable + applicationRuleMode = ApplicationRuleMode( + rawValue: UserDefaults.standard.string(forKey: Keys.applicationRuleMode) ?? "" + ) ?? .allowlist + allowlistedApplications = Self.loadApplications( + key: Keys.allowlistedApplications, + fallback: [.firefox] + ) + denylistedApplications = Self.loadApplications( + key: Keys.denylistedApplications, + fallback: [] + ) + if startAutomatically { + Task { @MainActor [weak self] in self?.start() } + } +} + +func runDiagnostics() { + guard !isDiagnosing else { return } + isDiagnosing = true + defer { isDiagnosing = false } + diagnosticReport = browserDiagnostics.run( + policy: applicationPolicy, + configuredApplications: activeApplicationRules, + now: Date() + ) +} + +func copyDiagnosticReport() { + guard let report = diagnosticReport else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(report.plainText, forType: .string) +} +``` + +Keep diagnostic state independent of `isWorking` and `statusText`, so running diagnostics does not impersonate or interrupt autofill. + +- [ ] **Step 4: Run focused and full tests** + +Run: + +```bash +swift test --disable-sandbox --filter BrowserDiagnosticsTests +swift test --disable-sandbox +``` + +Expected: all tests pass, and test construction does not prompt for macOS permissions. + +- [ ] **Step 5: Commit model integration** + +```bash +git add Sources/ApplePasswordBridge/BridgeModel.swift Sources/ApplePasswordBridge/BrowserDiagnostics.swift Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: expose one-shot browser diagnostics" +``` + +### Task 5: Add the menu diagnostics interface + +**Files:** +- Modify: `Sources/ApplePasswordBridge/BridgeApp.swift:59-67,123-140` + +- [ ] **Step 1: Build before the UI change to establish the baseline** + +Run `swift build --disable-sandbox`. + +Expected: build succeeds before modifying `BridgeApp.swift`. + +- [ ] **Step 2: Add the diagnostic controls** + +Insert a compact section after application rules and before permissions: + +```swift +VStack(alignment: .leading, spacing: 8) { + Button(action: model.runDiagnostics) { + Label( + model.isDiagnosing ? "正在诊断…" : "诊断当前授权窗口", + systemImage: "stethoscope" + ) + .frame(maxWidth: .infinity) + } + .disabled(model.isDiagnosing) + + if let report = model.diagnosticReport { + Text(report.summary) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + + HStack { + Text(report.generatedAt, style: .time) + .font(.caption2) + .foregroundStyle(.secondary) + Spacer() + Button("复制诊断报告", action: model.copyDiagnosticReport) + .controlSize(.small) + } + } +} +``` + +Keep the menu width at 340 points unless the localized summary clips in a release build; allow two summary lines instead of widening the menu. + +- [ ] **Step 3: Build and run unit tests** + +Run: + +```bash +swift build --disable-sandbox +swift test --disable-sandbox +``` + +Expected: build and all tests pass. + +- [ ] **Step 4: Commit the UI slice** + +```bash +git add Sources/ApplePasswordBridge/BridgeApp.swift +git commit -m "feat: show browser diagnostics in menu" +``` + +### Task 6: Package and manually validate the Arc diagnostic build + +**Files:** +- Modify only if validation reveals a diagnostic-only defect: `Sources/ApplePasswordBridge/BrowserDiagnostics.swift`, `BridgeModel.swift`, `BridgeApp.swift`, or `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Run clean verification** + +Run: + +```bash +swift test --disable-sandbox +swift build -c release --disable-sandbox +``` + +Expected: all tests pass and the release executable builds. + +- [ ] **Step 2: Build the app bundle** + +Run `make app`. + +Expected: `dist/Password Bridge.app` exists and `codesign --verify --deep --strict "dist/Password Bridge.app"` succeeds with the project's ad-hoc signature. + +- [ ] **Step 3: Manually collect one Arc report** + +1. Launch the diagnostic app build. +2. Confirm Accessibility permission is granted to this exact rebuilt app identity. +3. Keep Arc's iCloud Passwords authorization UI visible. +4. Click “诊断当前授权窗口”. +5. Confirm the UI names exactly one earliest failing boundary. +6. Click “复制诊断报告”. +7. Inspect the pasted text: it must contain Arc's bundle ID, PID, window/AX counts, title-match flags, extension flags, and role counts; it must not contain a six-digit code, extension identifier, raw AX body text, or Apple Passwords content. + +- [ ] **Step 4: Recheck normal behavior** + +With no diagnostic action running, confirm “立即填入”, the global hotkey, the 0.5-second automatic scan, application rules, and permission rows behave exactly as before. + +- [ ] **Step 5: Commit only validation-driven diagnostic corrections** + +If validation required a correction, first add a failing regression test, implement the minimal change, rerun the full suite, then commit: + +```bash +git add Sources/ApplePasswordBridge Tests/ApplePasswordBridgeTests +git commit -m "fix: harden Arc diagnostic reporting" +``` + +If no correction was required, do not create an empty commit. + +## Final acceptance checklist + +- [ ] `swift test --disable-sandbox` passes. +- [ ] `swift build -c release --disable-sandbox` passes. +- [ ] `make app` produces a valid ad-hoc signed application bundle. +- [ ] Diagnostic execution never calls code reading, focus, activation, or keyboard-event APIs. +- [ ] Copied reports contain no verification codes, extension identifiers, query strings, or raw AX text. +- [ ] An Arc run identifies one concrete failing boundary instead of the shared `authorizationWindowNotFound` message. +- [ ] Existing autofill matching logic and behavior remain unchanged. From 65dc16dfc157ae749c2b6b87f878d502a090da7b Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:44:45 +0800 Subject: [PATCH 03/39] chore: ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4c739aa..83dc698 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store .build/ +.worktrees/ dist/ *.xcuserstate From d65a65ccf116a2ad67d2fea0a85fb806def2e48a Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:48:11 +0800 Subject: [PATCH 04/39] feat: model browser diagnostic outcomes --- .../BrowserDiagnostics.swift | 68 +++++++++++++++++++ .../BrowserDiagnosticsTests.swift | 29 ++++++++ 2 files changed, 97 insertions(+) create mode 100644 Sources/ApplePasswordBridge/BrowserDiagnostics.swift create mode 100644 Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift new file mode 100644 index 0000000..11d9f92 --- /dev/null +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -0,0 +1,68 @@ +import Foundation + +public enum BrowserDiagnosticConclusion: String, Equatable { + case applicationNotRunning = "application_not_running" + case applicationRejectedByPolicy = "application_rejected_by_policy" + case noVisibleWindows = "no_visible_windows" + case windowTitleMismatch = "window_title_mismatch" + case accessibilityWindowsUnavailable = "ax_windows_unavailable" + case authorizationContextMismatch = "authorization_context_mismatch" + case inputRolesUnrecognized = "input_roles_unrecognized" + case targetRecognized = "target_recognized" +} + +public struct DiagnosticWindow: Equatable { + public let number: UInt32 + public let width: Int + public let height: Int + public let title: String + public let titleMatches: Bool + public init(number: UInt32, width: Int, height: Int, title: String, titleMatches: Bool) { + self.number = number; self.width = width; self.height = height; self.title = title; self.titleMatches = titleMatches + } +} + +public struct DiagnosticAccessibilityWindow: Equatable { + public let title: String + public let extensionURLSummary: String? + public let hasExtensionURL: Bool + public let hasPopupPath: Bool + public let hasICloudIdentity: Bool + public let hasAutofillTerms: Bool + public let hasVerificationCodeTerms: Bool + public let roleCounts: [String: Int] + public let stablePopupSignature: Bool + public let authorizationContextMatches: Bool + public init(title: String, extensionURLSummary: String?, hasExtensionURL: Bool, hasPopupPath: Bool, hasICloudIdentity: Bool, hasAutofillTerms: Bool, hasVerificationCodeTerms: Bool, roleCounts: [String: Int], stablePopupSignature: Bool, authorizationContextMatches: Bool) { + self.title = title; self.extensionURLSummary = extensionURLSummary; self.hasExtensionURL = hasExtensionURL; self.hasPopupPath = hasPopupPath; self.hasICloudIdentity = hasICloudIdentity; self.hasAutofillTerms = hasAutofillTerms; self.hasVerificationCodeTerms = hasVerificationCodeTerms; self.roleCounts = roleCounts; self.stablePopupSignature = stablePopupSignature; self.authorizationContextMatches = authorizationContextMatches + } + public var supportedInputCount: Int { ["AXTextField", "AXTextArea", "AXSecureTextField"].reduce(0) { $0 + (roleCounts[$1] ?? 0) } } +} + +public struct DiagnosticApplication: Equatable { + public let displayName: String + public let bundleIdentifier: String + public let processIdentifier: pid_t? + public let activationPolicy: String? + public let permitted: Bool + public let windows: [DiagnosticWindow] + public let accessibilityWindows: [DiagnosticAccessibilityWindow] + public init(displayName: String, bundleIdentifier: String, processIdentifier: pid_t?, activationPolicy: String?, permitted: Bool, windows: [DiagnosticWindow], accessibilityWindows: [DiagnosticAccessibilityWindow]) { + self.displayName = displayName; self.bundleIdentifier = bundleIdentifier; self.processIdentifier = processIdentifier; self.activationPolicy = activationPolicy; self.permitted = permitted; self.windows = windows; self.accessibilityWindows = accessibilityWindows + } +} + +public enum BrowserDiagnostics { + public static func evaluate(_ applications: [DiagnosticApplication]) -> BrowserDiagnosticConclusion { + guard applications.contains(where: { $0.processIdentifier != nil }) else { return .applicationNotRunning } + let running = applications.filter { $0.processIdentifier != nil } + guard running.contains(where: { $0.permitted }) else { return .applicationRejectedByPolicy } + let permitted = running.filter { $0.permitted } + guard permitted.contains(where: { $0.windows.contains { $0.width > 0 && $0.height > 0 } }) else { return .noVisibleWindows } + guard permitted.contains(where: { $0.windows.contains { $0.width > 0 && $0.height > 0 && $0.titleMatches } }) else { return .windowTitleMismatch } + guard permitted.contains(where: { !$0.accessibilityWindows.isEmpty }) else { return .accessibilityWindowsUnavailable } + guard permitted.contains(where: { $0.accessibilityWindows.contains { $0.stablePopupSignature && $0.authorizationContextMatches } }) else { return .authorizationContextMismatch } + guard permitted.contains(where: { $0.accessibilityWindows.contains { $0.stablePopupSignature && $0.authorizationContextMatches && $0.supportedInputCount > 0 } }) else { return .inputRolesUnrecognized } + return .targetRecognized + } +} diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift new file mode 100644 index 0000000..fd6fd4e --- /dev/null +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -0,0 +1,29 @@ +import XCTest +@testable import ApplePasswordBridge + +final class BrowserDiagnosticsTests: XCTestCase { + private func application(running: Bool = true, permitted: Bool = true, windows: [DiagnosticWindow] = [], accessibilityWindows: [DiagnosticAccessibilityWindow] = []) -> DiagnosticApplication { + DiagnosticApplication(displayName: "Arc", bundleIdentifier: "company.thebrowser.Browser", processIdentifier: running ? 42 : nil, activationPolicy: "regular", permitted: permitted, windows: windows, accessibilityWindows: accessibilityWindows) + } + + private let visible = DiagnosticWindow(number: 1, width: 800, height: 600, title: "Arc", titleMatches: true) + private let target = DiagnosticAccessibilityWindow(title: "Arc", extensionURLSummary: "ext", hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: true, hasAutofillTerms: true, hasVerificationCodeTerms: true, roleCounts: ["AXTextField": 1], stablePopupSignature: true, authorizationContextMatches: true) + + func test_conclusions_follow_earliest_failure_boundary() { + XCTAssertEqual(BrowserDiagnostics.evaluate([application(running: false)]), .applicationNotRunning) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(permitted: false)]), .applicationRejectedByPolicy) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [])]), .noVisibleWindows) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [DiagnosticWindow(number: 1, width: 1, height: 1, title: "Other", titleMatches: false)])]), .windowTitleMismatch) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [visible])]), .accessibilityWindowsUnavailable) + let noAuth = DiagnosticAccessibilityWindow(title: "Arc", extensionURLSummary: nil, hasExtensionURL: false, hasPopupPath: false, hasICloudIdentity: false, hasAutofillTerms: false, hasVerificationCodeTerms: false, roleCounts: [:], stablePopupSignature: false, authorizationContextMatches: false) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [visible], accessibilityWindows: [noAuth])]), .authorizationContextMismatch) + let noInput = DiagnosticAccessibilityWindow(title: "Arc", extensionURLSummary: "ext", hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: true, hasAutofillTerms: true, hasVerificationCodeTerms: true, roleCounts: ["AXButton": 2], stablePopupSignature: true, authorizationContextMatches: true) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [visible], accessibilityWindows: [noInput])]), .inputRolesUnrecognized) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [visible], accessibilityWindows: [target])]), .targetRecognized) + } + + func test_supported_input_count_only_counts_text_roles() { + let window = DiagnosticAccessibilityWindow(title: "", extensionURLSummary: nil, hasExtensionURL: false, hasPopupPath: false, hasICloudIdentity: false, hasAutofillTerms: false, hasVerificationCodeTerms: false, roleCounts: ["AXTextField": 2, "AXTextArea": 3, "AXSecureTextField": 4, "AXButton": 99], stablePopupSignature: false, authorizationContextMatches: false) + XCTAssertEqual(window.supportedInputCount, 9) + } +} From 6a3d96f29ee07a61893d3624e7806f4b4a0a668f Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:50:57 +0800 Subject: [PATCH 05/39] fix: align diagnostic outcome evaluation --- Sources/ApplePasswordBridge/BrowserDiagnostics.swift | 9 +++++---- .../BrowserDiagnosticsTests.swift | 9 +++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index 11d9f92..3800831 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -58,11 +58,12 @@ public enum BrowserDiagnostics { let running = applications.filter { $0.processIdentifier != nil } guard running.contains(where: { $0.permitted }) else { return .applicationRejectedByPolicy } let permitted = running.filter { $0.permitted } - guard permitted.contains(where: { $0.windows.contains { $0.width > 0 && $0.height > 0 } }) else { return .noVisibleWindows } - guard permitted.contains(where: { $0.windows.contains { $0.width > 0 && $0.height > 0 && $0.titleMatches } }) else { return .windowTitleMismatch } + guard permitted.contains(where: { !$0.windows.isEmpty }) else { return .noVisibleWindows } + guard permitted.contains(where: { $0.windows.contains { $0.titleMatches } }) else { return .windowTitleMismatch } guard permitted.contains(where: { !$0.accessibilityWindows.isEmpty }) else { return .accessibilityWindowsUnavailable } - guard permitted.contains(where: { $0.accessibilityWindows.contains { $0.stablePopupSignature && $0.authorizationContextMatches } }) else { return .authorizationContextMismatch } - guard permitted.contains(where: { $0.accessibilityWindows.contains { $0.stablePopupSignature && $0.authorizationContextMatches && $0.supportedInputCount > 0 } }) else { return .inputRolesUnrecognized } + let matching = permitted.flatMap { $0.accessibilityWindows }.filter { $0.stablePopupSignature || $0.authorizationContextMatches } + guard !matching.isEmpty else { return .authorizationContextMismatch } + guard matching.contains(where: { $0.supportedInputCount > 0 }) else { return .inputRolesUnrecognized } return .targetRecognized } } diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index fd6fd4e..cd89382 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -20,6 +20,15 @@ final class BrowserDiagnosticsTests: XCTestCase { let noInput = DiagnosticAccessibilityWindow(title: "Arc", extensionURLSummary: "ext", hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: true, hasAutofillTerms: true, hasVerificationCodeTerms: true, roleCounts: ["AXButton": 2], stablePopupSignature: true, authorizationContextMatches: true) XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [visible], accessibilityWindows: [noInput])]), .inputRolesUnrecognized) XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [visible], accessibilityWindows: [target])]), .targetRecognized) + + let stableOnly = DiagnosticAccessibilityWindow(title: "Arc", extensionURLSummary: "ext", hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: true, hasAutofillTerms: true, hasVerificationCodeTerms: true, roleCounts: ["AXTextField": 1], stablePopupSignature: true, authorizationContextMatches: false) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [visible], accessibilityWindows: [stableOnly])]), .targetRecognized) + + let contextOnly = DiagnosticAccessibilityWindow(title: "Arc", extensionURLSummary: "ext", hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: true, hasAutofillTerms: true, hasVerificationCodeTerms: true, roleCounts: ["AXTextField": 1], stablePopupSignature: false, authorizationContextMatches: true) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [visible], accessibilityWindows: [contextOnly])]), .targetRecognized) + + let zeroSized = DiagnosticWindow(number: 1, width: 0, height: 0, title: "Other", titleMatches: false) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [zeroSized])]), .windowTitleMismatch) } func test_supported_input_count_only_counts_text_roles() { From a7aebe5cb3f57d3bc607d5646b13e93e45b5f17f Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:03:06 +0800 Subject: [PATCH 06/39] test: cover diagnostic outcome precedence --- .../BrowserDiagnostics.swift | 25 ++++++++++++++-- .../BrowserDiagnosticsTests.swift | 29 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index 3800831..09b4df4 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -18,7 +18,11 @@ public struct DiagnosticWindow: Equatable { public let title: String public let titleMatches: Bool public init(number: UInt32, width: Int, height: Int, title: String, titleMatches: Bool) { - self.number = number; self.width = width; self.height = height; self.title = title; self.titleMatches = titleMatches + self.number = number + self.width = width + self.height = height + self.title = title + self.titleMatches = titleMatches } } @@ -34,7 +38,16 @@ public struct DiagnosticAccessibilityWindow: Equatable { public let stablePopupSignature: Bool public let authorizationContextMatches: Bool public init(title: String, extensionURLSummary: String?, hasExtensionURL: Bool, hasPopupPath: Bool, hasICloudIdentity: Bool, hasAutofillTerms: Bool, hasVerificationCodeTerms: Bool, roleCounts: [String: Int], stablePopupSignature: Bool, authorizationContextMatches: Bool) { - self.title = title; self.extensionURLSummary = extensionURLSummary; self.hasExtensionURL = hasExtensionURL; self.hasPopupPath = hasPopupPath; self.hasICloudIdentity = hasICloudIdentity; self.hasAutofillTerms = hasAutofillTerms; self.hasVerificationCodeTerms = hasVerificationCodeTerms; self.roleCounts = roleCounts; self.stablePopupSignature = stablePopupSignature; self.authorizationContextMatches = authorizationContextMatches + self.title = title + self.extensionURLSummary = extensionURLSummary + self.hasExtensionURL = hasExtensionURL + self.hasPopupPath = hasPopupPath + self.hasICloudIdentity = hasICloudIdentity + self.hasAutofillTerms = hasAutofillTerms + self.hasVerificationCodeTerms = hasVerificationCodeTerms + self.roleCounts = roleCounts + self.stablePopupSignature = stablePopupSignature + self.authorizationContextMatches = authorizationContextMatches } public var supportedInputCount: Int { ["AXTextField", "AXTextArea", "AXSecureTextField"].reduce(0) { $0 + (roleCounts[$1] ?? 0) } } } @@ -48,7 +61,13 @@ public struct DiagnosticApplication: Equatable { public let windows: [DiagnosticWindow] public let accessibilityWindows: [DiagnosticAccessibilityWindow] public init(displayName: String, bundleIdentifier: String, processIdentifier: pid_t?, activationPolicy: String?, permitted: Bool, windows: [DiagnosticWindow], accessibilityWindows: [DiagnosticAccessibilityWindow]) { - self.displayName = displayName; self.bundleIdentifier = bundleIdentifier; self.processIdentifier = processIdentifier; self.activationPolicy = activationPolicy; self.permitted = permitted; self.windows = windows; self.accessibilityWindows = accessibilityWindows + self.displayName = displayName + self.bundleIdentifier = bundleIdentifier + self.processIdentifier = processIdentifier + self.activationPolicy = activationPolicy + self.permitted = permitted + self.windows = windows + self.accessibilityWindows = accessibilityWindows } } diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index cd89382..d96c3bf 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -35,4 +35,33 @@ final class BrowserDiagnosticsTests: XCTestCase { let window = DiagnosticAccessibilityWindow(title: "", extensionURLSummary: nil, hasExtensionURL: false, hasPopupPath: false, hasICloudIdentity: false, hasAutofillTerms: false, hasVerificationCodeTerms: false, roleCounts: ["AXTextField": 2, "AXTextArea": 3, "AXSecureTextField": 4, "AXButton": 99], stablePopupSignature: false, authorizationContextMatches: false) XCTAssertEqual(window.supportedInputCount, 9) } + + func test_running_unpermitted_match_is_rejected_before_window_and_accessibility_checks() { + XCTAssertEqual( + BrowserDiagnostics.evaluate([application(permitted: false, windows: [visible], accessibilityWindows: [target])]), + .applicationRejectedByPolicy + ) + } + + func test_permitted_window_title_mismatch_precedes_matching_accessibility_input() { + let mismatched = DiagnosticWindow(number: 1, width: 800, height: 600, title: "Other", titleMatches: false) + XCTAssertEqual( + BrowserDiagnostics.evaluate([application(windows: [mismatched], accessibilityWindows: [target])]), + .windowTitleMismatch + ) + } + + func test_matching_accessibility_window_is_not_shadowed_by_first_mismatch() { + let mismatch = DiagnosticAccessibilityWindow(title: "Other", extensionURLSummary: nil, hasExtensionURL: false, hasPopupPath: false, hasICloudIdentity: false, hasAutofillTerms: false, hasVerificationCodeTerms: false, roleCounts: [:], stablePopupSignature: false, authorizationContextMatches: false) + XCTAssertEqual( + BrowserDiagnostics.evaluate([application(windows: [visible], accessibilityWindows: [mismatch, target])]), + .targetRecognized + ) + } + + func test_permitted_running_application_succeeds_among_other_application_states() { + let other = application(running: false, permitted: false, windows: [visible], accessibilityWindows: [target]) + let permitted = application(windows: [visible], accessibilityWindows: [target]) + XCTAssertEqual(BrowserDiagnostics.evaluate([other, permitted]), .targetRecognized) + } } From 3a5392f20fb21b6c18a94828a32b8545bd068c00 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:06:36 +0800 Subject: [PATCH 07/39] feat: redact and render diagnostic reports --- .../BrowserDiagnostics.swift | 53 +++++++++++++++++++ .../BrowserDiagnosticsTests.swift | 20 +++++++ 2 files changed, 73 insertions(+) diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index 09b4df4..e4b7d18 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -9,6 +9,34 @@ public enum BrowserDiagnosticConclusion: String, Equatable { case authorizationContextMismatch = "authorization_context_mismatch" case inputRolesUnrecognized = "input_roles_unrecognized" case targetRecognized = "target_recognized" + public var localizedSummary: String { switch self { + case .applicationNotRunning: return "目标应用未运行" + case .applicationRejectedByPolicy: return "应用被规则拒绝" + case .noVisibleWindows: return "没有可见窗口" + case .windowTitleMismatch: return "窗口标题不匹配" + case .accessibilityWindowsUnavailable: return "无法获取辅助功能窗口" + case .authorizationContextMismatch: return "授权上下文不匹配" + case .inputRolesUnrecognized: return "未识别到输入控件" + case .targetRecognized: return "已识别目标弹窗" } + } +} + +public enum DiagnosticRedactor { + private static let code = try! NSRegularExpression(pattern: #"(? String { + var s = text + s = ext.stringByReplacingMatches(in: s, range: NSRange(s.startIndex..., in: s), withTemplate: "$1://$2") + s = code.stringByReplacingMatches(in: s, range: NSRange(s.startIndex..., in: s), withTemplate: "") + s = number.stringByReplacingMatches(in: s, range: NSRange(s.startIndex..., in: s), withTemplate: "") + return s + } + public static func extensionURLSummary(from text: String) -> String? { + guard let m = ext.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)), let r = Range(m.range, in: text) else { return nil } + return redactText(String(text[r])) + } + public static func redactTitle(_ title: String?) -> String { String(redactText(title ?? "").prefix(160)) } } public struct DiagnosticWindow: Equatable { @@ -86,3 +114,28 @@ public enum BrowserDiagnostics { return .targetRecognized } } + +struct BrowserDiagnosticReport: Equatable { + public let generatedAt: Date + public let ruleMode: ApplicationRuleMode + public let applications: [DiagnosticApplication] + init(generatedAt: Date, ruleMode: ApplicationRuleMode, applications: [DiagnosticApplication]) { self.generatedAt = generatedAt; self.ruleMode = ruleMode; self.applications = applications } + public var conclusion: BrowserDiagnosticConclusion { BrowserDiagnostics.evaluate(applications) } + public var summary: String { conclusion.localizedSummary } + func plainText(policy: ApplicationRulePolicy) -> String { + var lines = ["generatedAt=\(generatedAt.timeIntervalSince1970)", "ruleMode=\(ruleMode.rawValue)", "conclusion=\(conclusion.rawValue)", "summary=\(summary)"] + for app in applications { + let pid = app.processIdentifier.map(String.init) ?? "nil" + lines.append("app=\(app.displayName) bundle=\(app.bundleIdentifier) pid=\(pid) activation=\(app.activationPolicy ?? "nil") permitted=\(app.permitted)") + for w in app.windows { lines.append("cgWindow=\(w.number) size=\(w.width)x\(w.height) title=\(DiagnosticRedactor.redactTitle(w.title)) match=\(w.titleMatches)") } + for ax in app.accessibilityWindows { + let roles = ax.roleCounts.keys.sorted().map { "\($0)=\(ax.roleCounts[$0]!)" }.joined(separator: ", ") + lines.append("axTitle=\(DiagnosticRedactor.redactTitle(ax.title)) extension=\(ax.extensionURLSummary.map(DiagnosticRedactor.redactText) ?? "nil") flags=\(ax.hasExtensionURL),\(ax.hasPopupPath),\(ax.hasICloudIdentity),\(ax.hasAutofillTerms),\(ax.hasVerificationCodeTerms) roles=\(roles) existingMatch=\(ax.stablePopupSignature || ax.authorizationContextMatches)") + } + } + return lines.joined(separator: "\n") + } + public var plainText: String { plainText(policy: ApplicationRulePolicy(mode: ruleMode, allowlist: [], denylist: [])) } +} + +protocol BrowserDiagnosticsRunning { func run(policy: ApplicationRulePolicy, configuredApplications: [TargetApplication], now: Date) -> BrowserDiagnosticReport } diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index d96c3bf..1f15d14 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -64,4 +64,24 @@ final class BrowserDiagnosticsTests: XCTestCase { let permitted = application(windows: [visible], accessibilityWindows: [target]) XCTAssertEqual(BrowserDiagnostics.evaluate([other, permitted]), .targetRecognized) } + func test_redactor_masks_codes_numbers_extensions_and_titles() { + XCTAssertEqual(DiagnosticRedactor.redactText("code 123456 and 1 2 3 4 5 6 and 1-2-3-4-5-6"), "code and and ") + let ext = "chrome-extension://abcdefghijklmnop/page_popup.html?popupWindow=42" + XCTAssertEqual(DiagnosticRedactor.extensionURLSummary(from: ext), "chrome-extension:///page_popup.html") + XCTAssertEqual(DiagnosticRedactor.redactTitle(nil), "") + XCTAssertLessThanOrEqual(DiagnosticRedactor.redactTitle(String(repeating: "a", count: 200)).count, 160) + } + + func test_report_plain_text_is_deterministic_and_redacted() { + let ax = DiagnosticAccessibilityWindow(title: "AX 123456", extensionURLSummary: "chrome-extension://abcdefghijklmnop/page_popup.html?popupWindow=42", hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: true, hasAutofillTerms: true, hasVerificationCodeTerms: true, roleCounts: ["AXButton": 2, "AXTextField": 1], stablePopupSignature: true, authorizationContextMatches: true) + let app = DiagnosticApplication(displayName: "Arc", bundleIdentifier: "company.thebrowser.Browser", processIdentifier: 42, activationPolicy: "regular", permitted: true, windows: [DiagnosticWindow(number: 7, width: 800, height: 600, title: "code 123456", titleMatches: true)], accessibilityWindows: [ax]) + let policy = ApplicationRulePolicy(mode: .allowlist, allowlist: [app.bundleIdentifier], denylist: []) + let report = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 0), ruleMode: .allowlist, applications: [app]) + let text = report.plainText(policy: policy) + XCTAssertTrue(text.contains("")); XCTAssertFalse(text.contains("123456")); XCTAssertFalse(text.contains("abcdefghijklmnop")); XCTAssertFalse(text.contains("popupWindow=42")); XCTAssertTrue(text.contains("AXButton=2, AXTextField=1")) + } + + func test_all_conclusions_have_chinese_summary() { + for c in [BrowserDiagnosticConclusion.applicationNotRunning,.applicationRejectedByPolicy,.noVisibleWindows,.windowTitleMismatch,.accessibilityWindowsUnavailable,.authorizationContextMismatch,.inputRolesUnrecognized,.targetRecognized] { XCTAssertFalse(c.localizedSummary.isEmpty) } + } } From 33f0b6864cb8e67ca9a198d3369dedc5136eaaf3 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:10:24 +0800 Subject: [PATCH 08/39] fix: redact diagnostic observations at capture --- .../ApplePasswordBridge/BrowserDiagnostics.swift | 6 +++--- .../BrowserDiagnosticsTests.swift | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index e4b7d18..1a7e6d6 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -49,7 +49,7 @@ public struct DiagnosticWindow: Equatable { self.number = number self.width = width self.height = height - self.title = title + self.title = DiagnosticRedactor.redactTitle(title) self.titleMatches = titleMatches } } @@ -66,8 +66,8 @@ public struct DiagnosticAccessibilityWindow: Equatable { public let stablePopupSignature: Bool public let authorizationContextMatches: Bool public init(title: String, extensionURLSummary: String?, hasExtensionURL: Bool, hasPopupPath: Bool, hasICloudIdentity: Bool, hasAutofillTerms: Bool, hasVerificationCodeTerms: Bool, roleCounts: [String: Int], stablePopupSignature: Bool, authorizationContextMatches: Bool) { - self.title = title - self.extensionURLSummary = extensionURLSummary + self.title = DiagnosticRedactor.redactTitle(title) + self.extensionURLSummary = extensionURLSummary.flatMap { DiagnosticRedactor.extensionURLSummary(from: $0) } self.hasExtensionURL = hasExtensionURL self.hasPopupPath = hasPopupPath self.hasICloudIdentity = hasICloudIdentity diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index 1f15d14..9b237cf 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -72,6 +72,20 @@ final class BrowserDiagnosticsTests: XCTestCase { XCTAssertLessThanOrEqual(DiagnosticRedactor.redactTitle(String(repeating: "a", count: 200)).count, 160) } + func test_observation_initializers_redact_sensitive_fields_at_capture() { + let window = DiagnosticWindow(number: 1, width: 1, height: 1, title: "code 123456", titleMatches: false) + XCTAssertFalse(window.title.contains("123456")) + XCTAssertTrue(window.title.contains("")) + + let ext = "chrome-extension://abcdefghijklmnop/page_popup.html?popupWindow=42" + let ax = DiagnosticAccessibilityWindow(title: "code 123456", extensionURLSummary: ext, hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: false, hasAutofillTerms: false, hasVerificationCodeTerms: false, roleCounts: [:], stablePopupSignature: false, authorizationContextMatches: false) + XCTAssertFalse(ax.title.contains("123456")) + XCTAssertTrue(ax.title.contains("")) + XCTAssertEqual(ax.extensionURLSummary, "chrome-extension:///page_popup.html") + XCTAssertFalse(ax.extensionURLSummary?.contains("abcdefghijklmnop") ?? false) + XCTAssertFalse(ax.extensionURLSummary?.contains("popupWindow=42") ?? false) + } + func test_report_plain_text_is_deterministic_and_redacted() { let ax = DiagnosticAccessibilityWindow(title: "AX 123456", extensionURLSummary: "chrome-extension://abcdefghijklmnop/page_popup.html?popupWindow=42", hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: true, hasAutofillTerms: true, hasVerificationCodeTerms: true, roleCounts: ["AXButton": 2, "AXTextField": 1], stablePopupSignature: true, authorizationContextMatches: true) let app = DiagnosticApplication(displayName: "Arc", bundleIdentifier: "company.thebrowser.Browser", processIdentifier: 42, activationPolicy: "regular", permitted: true, windows: [DiagnosticWindow(number: 7, width: 800, height: 600, title: "code 123456", titleMatches: true)], accessibilityWindows: [ax]) From f62bd5df7ba0a8c3b8ad5b4a856453efd8c8d008 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:15:32 +0800 Subject: [PATCH 09/39] fix: preserve diagnostic URL paths --- .../BrowserDiagnostics.swift | 39 +++++++++++++------ .../BrowserDiagnosticsTests.swift | 6 ++- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index 1a7e6d6..c0ca88c 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -24,17 +24,32 @@ public enum BrowserDiagnosticConclusion: String, Equatable { public enum DiagnosticRedactor { private static let code = try! NSRegularExpression(pattern: #"(? String { + let scheme = Range(match.range(at: 1), in: text).map { String(text[$0]) } ?? "chrome-extension" + let path = match.range(at: 2).location != NSNotFound ? (Range(match.range(at: 2), in: text).map { String(text[$0]) } ?? "") : "" + return "\(scheme)://\(path)" + } public static func redactText(_ text: String) -> String { - var s = text - s = ext.stringByReplacingMatches(in: s, range: NSRange(s.startIndex..., in: s), withTemplate: "$1://$2") - s = code.stringByReplacingMatches(in: s, range: NSRange(s.startIndex..., in: s), withTemplate: "") + var out = "", cursor = text.startIndex + let range = NSRange(text.startIndex..., in: text) + for match in ext.matches(in: text, range: range) { + guard let r = Range(match.range, in: text) else { continue } + out += redactNonURL(String(text[cursor.. String { + var s = code.stringByReplacingMatches(in: text, range: NSRange(text.startIndex..., in: text), withTemplate: "") s = number.stringByReplacingMatches(in: s, range: NSRange(s.startIndex..., in: s), withTemplate: "") return s } public static func extensionURLSummary(from text: String) -> String? { - guard let m = ext.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)), let r = Range(m.range, in: text) else { return nil } - return redactText(String(text[r])) + guard let m = ext.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) else { return nil } + return extensionSummary(text, match: m) } public static func redactTitle(_ title: String?) -> String { String(redactText(title ?? "").prefix(160)) } } @@ -122,20 +137,20 @@ struct BrowserDiagnosticReport: Equatable { init(generatedAt: Date, ruleMode: ApplicationRuleMode, applications: [DiagnosticApplication]) { self.generatedAt = generatedAt; self.ruleMode = ruleMode; self.applications = applications } public var conclusion: BrowserDiagnosticConclusion { BrowserDiagnostics.evaluate(applications) } public var summary: String { conclusion.localizedSummary } - func plainText(policy: ApplicationRulePolicy) -> String { + private func renderPlainText() -> String { var lines = ["generatedAt=\(generatedAt.timeIntervalSince1970)", "ruleMode=\(ruleMode.rawValue)", "conclusion=\(conclusion.rawValue)", "summary=\(summary)"] - for app in applications { + for app in applications.sorted(by: { ($0.bundleIdentifier, $0.processIdentifier ?? -1) < ($1.bundleIdentifier, $1.processIdentifier ?? -1) }) { let pid = app.processIdentifier.map(String.init) ?? "nil" lines.append("app=\(app.displayName) bundle=\(app.bundleIdentifier) pid=\(pid) activation=\(app.activationPolicy ?? "nil") permitted=\(app.permitted)") - for w in app.windows { lines.append("cgWindow=\(w.number) size=\(w.width)x\(w.height) title=\(DiagnosticRedactor.redactTitle(w.title)) match=\(w.titleMatches)") } - for ax in app.accessibilityWindows { + for w in app.windows.sorted(by: { $0.number < $1.number }) { lines.append("cgWindow=\(w.number) size=\(w.width)x\(w.height) title=\(DiagnosticRedactor.redactTitle(w.title)) match=\(w.titleMatches)") } + for ax in app.accessibilityWindows.sorted(by: { (DiagnosticRedactor.redactTitle($0.title), $0.extensionURLSummary ?? "") < (DiagnosticRedactor.redactTitle($1.title), $1.extensionURLSummary ?? "") }) { let roles = ax.roleCounts.keys.sorted().map { "\($0)=\(ax.roleCounts[$0]!)" }.joined(separator: ", ") - lines.append("axTitle=\(DiagnosticRedactor.redactTitle(ax.title)) extension=\(ax.extensionURLSummary.map(DiagnosticRedactor.redactText) ?? "nil") flags=\(ax.hasExtensionURL),\(ax.hasPopupPath),\(ax.hasICloudIdentity),\(ax.hasAutofillTerms),\(ax.hasVerificationCodeTerms) roles=\(roles) existingMatch=\(ax.stablePopupSignature || ax.authorizationContextMatches)") + lines.append("axTitle=\(DiagnosticRedactor.redactTitle(ax.title)) extension=\(ax.extensionURLSummary ?? "nil") flags=\(ax.hasExtensionURL),\(ax.hasPopupPath),\(ax.hasICloudIdentity),\(ax.hasAutofillTerms),\(ax.hasVerificationCodeTerms) roles=\(roles) existingMatch=\(ax.stablePopupSignature || ax.authorizationContextMatches)") } } return lines.joined(separator: "\n") } - public var plainText: String { plainText(policy: ApplicationRulePolicy(mode: ruleMode, allowlist: [], denylist: [])) } + public var plainText: String { renderPlainText() } } protocol BrowserDiagnosticsRunning { func run(policy: ApplicationRulePolicy, configuredApplications: [TargetApplication], now: Date) -> BrowserDiagnosticReport } diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index 9b237cf..526f268 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -68,6 +68,9 @@ final class BrowserDiagnosticsTests: XCTestCase { XCTAssertEqual(DiagnosticRedactor.redactText("code 123456 and 1 2 3 4 5 6 and 1-2-3-4-5-6"), "code and and ") let ext = "chrome-extension://abcdefghijklmnop/page_popup.html?popupWindow=42" XCTAssertEqual(DiagnosticRedactor.extensionURLSummary(from: ext), "chrome-extension:///page_popup.html") + XCTAssertEqual(DiagnosticRedactor.extensionURLSummary(from: "chrome-extension://id/page123.html?x=42#secret"), "chrome-extension:///page123.html") + XCTAssertEqual(DiagnosticRedactor.redactText("version 42 chrome-extension://id/page123.html?x=42#secret"), "version chrome-extension:///page123.html") + XCTAssertEqual(DiagnosticRedactor.extensionURLSummary(from: "moz-extension://id/page123.html?x=42#secret"), "moz-extension:///page123.html") XCTAssertEqual(DiagnosticRedactor.redactTitle(nil), "") XCTAssertLessThanOrEqual(DiagnosticRedactor.redactTitle(String(repeating: "a", count: 200)).count, 160) } @@ -89,9 +92,8 @@ final class BrowserDiagnosticsTests: XCTestCase { func test_report_plain_text_is_deterministic_and_redacted() { let ax = DiagnosticAccessibilityWindow(title: "AX 123456", extensionURLSummary: "chrome-extension://abcdefghijklmnop/page_popup.html?popupWindow=42", hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: true, hasAutofillTerms: true, hasVerificationCodeTerms: true, roleCounts: ["AXButton": 2, "AXTextField": 1], stablePopupSignature: true, authorizationContextMatches: true) let app = DiagnosticApplication(displayName: "Arc", bundleIdentifier: "company.thebrowser.Browser", processIdentifier: 42, activationPolicy: "regular", permitted: true, windows: [DiagnosticWindow(number: 7, width: 800, height: 600, title: "code 123456", titleMatches: true)], accessibilityWindows: [ax]) - let policy = ApplicationRulePolicy(mode: .allowlist, allowlist: [app.bundleIdentifier], denylist: []) let report = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 0), ruleMode: .allowlist, applications: [app]) - let text = report.plainText(policy: policy) + let text = report.plainText XCTAssertTrue(text.contains("")); XCTAssertFalse(text.contains("123456")); XCTAssertFalse(text.contains("abcdefghijklmnop")); XCTAssertFalse(text.contains("popupWindow=42")); XCTAssertTrue(text.contains("AXButton=2, AXTextField=1")) } From ca98600ff29be75bd19bec8891ced5c602dae3dc Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:30:26 +0800 Subject: [PATCH 10/39] feat: collect browser recognition diagnostics --- .../BrowserDiagnostics.swift | 55 ++++++++++++++++++- Sources/ApplePasswordBridge/CodeParser.swift | 12 +++- .../BrowserDiagnosticsTests.swift | 46 ++++++++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index c0ca88c..c7c636d 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -1,4 +1,6 @@ import Foundation +import AppKit +import CoreGraphics public enum BrowserDiagnosticConclusion: String, Equatable { case applicationNotRunning = "application_not_running" @@ -114,7 +116,10 @@ public struct DiagnosticApplication: Equatable { } } -public enum BrowserDiagnostics { +struct DiagnosticNode { let role: String; let text: String } + +public final class BrowserDiagnostics: BrowserDiagnosticsRunning { + public init() {} public static func evaluate(_ applications: [DiagnosticApplication]) -> BrowserDiagnosticConclusion { guard applications.contains(where: { $0.processIdentifier != nil }) else { return .applicationNotRunning } let running = applications.filter { $0.processIdentifier != nil } @@ -128,6 +133,54 @@ public enum BrowserDiagnostics { guard matching.contains(where: { $0.supportedInputCount > 0 }) else { return .inputRolesUnrecognized } return .targetRecognized } + + static func makeAccessibilityObservation(title: String?, nodes: [DiagnosticNode]) -> DiagnosticAccessibilityWindow { + let combined = nodes.map(\.text).joined(separator: "\n") + let all = [title ?? "", combined].joined(separator: "\n") + let lower = all.lowercased() + let hasURL = lower.contains("chrome-extension://") || lower.contains("moz-extension://") + let popup = hasURL && lower.contains("/page_popup.html") + let icloud = AuthorizationContext.isICloudPasswordWindowTitle(title) || lower.contains("icloud 密码") || lower.contains("icloud passwords") + var roles: [String: Int] = [:] + for n in nodes { roles[n.role, default: 0] += 1 } + return DiagnosticAccessibilityWindow(title: title ?? "", extensionURLSummary: DiagnosticRedactor.extensionURLSummary(from: combined), hasExtensionURL: hasURL, hasPopupPath: popup, hasICloudIdentity: icloud, hasAutofillTerms: AuthorizationContext.hasAutofillTerms(all), hasVerificationCodeTerms: AuthorizationContext.hasVerificationCodeTerms(all), roleCounts: roles, stablePopupSignature: AuthorizationContext.isBrowserExtensionPopup(title: title, text: combined), authorizationContextMatches: AuthorizationContext.isBrowserExtensionAuthorization(title: title, text: combined)) + } + + func run(policy: ApplicationRulePolicy, configuredApplications: [TargetApplication], now: Date) -> BrowserDiagnosticReport { + let apps: [NSRunningApplication] + let targets: [TargetApplication] + switch policy.mode { + case .allowlist: targets = configuredApplications + case .denylist: + let running = NSWorkspace.shared.runningApplications.filter { $0.activationPolicy == .regular && policy.permits(bundleIdentifier: $0.bundleIdentifier ?? "") } + apps = running.sorted { (($0.bundleIdentifier ?? ""), $0.processIdentifier) < (($1.bundleIdentifier ?? ""), $1.processIdentifier) }.prefix(12).map { $0 } + targets = apps.compactMap { guard let b = $0.bundleIdentifier else { return nil }; return TargetApplication(bundleIdentifier: b, displayName: $0.localizedName ?? b) } + } + let cg = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? [] + var result: [DiagnosticApplication] = [] + for target in targets { + let app = NSRunningApplication.runningApplications(withBundleIdentifier: target.bundleIdentifier).first + let permitted = policy.permits(bundleIdentifier: target.bundleIdentifier) + let pid = app?.processIdentifier + let windows = cg.filter { ($0[kCGWindowOwnerPID as String] as? pid_t) == pid }.compactMap { info -> DiagnosticWindow? in + guard let num = info[kCGWindowNumber as String] as? UInt32, let bounds = info[kCGWindowBounds as String] as? [String: Any], let rect = CGRect(dictionaryRepresentation: bounds as CFDictionary) else { return nil } + let raw = info[kCGWindowName as String] as? String + return DiagnosticWindow(number: num, width: Int(rect.width), height: Int(rect.height), title: raw ?? "", titleMatches: AuthorizationContext.isICloudPasswordWindowTitle(raw)) + } + var axWindows: [DiagnosticAccessibilityWindow] = [] + if let app, permitted { + let ax = AXUIElementCreateApplication(app.processIdentifier) + AccessibilityTree.enableEnhancedUserInterface(ax) + for w in AccessibilityTree.windows(of: ax) { + let nodes = AccessibilityTree.collect(from: w, maxDepth: 12, maxNodes: 800).map { DiagnosticNode(role: $0.role, text: $0.text) } + axWindows.append(Self.makeAccessibilityObservation(title: AccessibilityTree.title(of: w), nodes: nodes)) + } + } + let activation: String? = app.map { switch $0.activationPolicy { case .regular: return "regular"; case .accessory: return "accessory"; case .prohibited: return "prohibited"; default: return "unknown" } } + result.append(DiagnosticApplication(displayName: target.displayName, bundleIdentifier: target.bundleIdentifier, processIdentifier: pid, activationPolicy: activation, permitted: permitted, windows: windows, accessibilityWindows: axWindows)) + } + return BrowserDiagnosticReport(generatedAt: now, ruleMode: policy.mode, applications: result) + } } struct BrowserDiagnosticReport: Equatable { diff --git a/Sources/ApplePasswordBridge/CodeParser.swift b/Sources/ApplePasswordBridge/CodeParser.swift index cc87ca8..60ec688 100644 --- a/Sources/ApplePasswordBridge/CodeParser.swift +++ b/Sources/ApplePasswordBridge/CodeParser.swift @@ -9,10 +9,18 @@ enum AuthorizationContext { "验证码", "verification code" ] - static func isApplePasswordAuthorization(_ text: String) -> Bool { + static func hasAutofillTerms(_ text: String) -> Bool { let value = text.lowercased() return autofillTerms.contains(where: value.contains) - && codeTerms.contains(where: value.contains) + } + + static func hasVerificationCodeTerms(_ text: String) -> Bool { + let value = text.lowercased() + return codeTerms.contains(where: value.contains) + } + + static func isApplePasswordAuthorization(_ text: String) -> Bool { + return hasAutofillTerms(text) && hasVerificationCodeTerms(text) } static func isBrowserExtensionAuthorization(title: String?, text: String) -> Bool { diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index 526f268..cb7f9a8 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -100,4 +100,50 @@ final class BrowserDiagnosticsTests: XCTestCase { func test_all_conclusions_have_chinese_summary() { for c in [BrowserDiagnosticConclusion.applicationNotRunning,.applicationRejectedByPolicy,.noVisibleWindows,.windowTitleMismatch,.accessibilityWindowsUnavailable,.authorizationContextMismatch,.inputRolesUnrecognized,.targetRecognized] { XCTAssertFalse(c.localizedSummary.isEmpty) } } + + func test_arc_allowlist_policy_permits_arc() { + let policy = ApplicationRulePolicy(mode: .allowlist, allowlist: ["company.thebrowser.Browser"], denylist: []) + XCTAssertTrue(policy.permits(bundleIdentifier: "company.thebrowser.Browser")) + } + + func test_accessibility_observation_extracts_redacted_extension_and_roles() { + let observation = BrowserDiagnostics.makeAccessibilityObservation( + title: "iCloud Passwords", + nodes: [ + DiagnosticNode(role: "AXStaticText", text: "chrome-extension://secret-id/page_popup.html"), + DiagnosticNode(role: "AXTextField", text: "") + ] + ) + XCTAssertTrue(observation.hasExtensionURL) + XCTAssertTrue(observation.hasPopupPath) + XCTAssertTrue(observation.hasICloudIdentity) + XCTAssertEqual(observation.roleCounts["AXStaticText"], 1) + XCTAssertEqual(observation.roleCounts["AXTextField"], 1) + XCTAssertEqual(observation.extensionURLSummary, "chrome-extension:///page_popup.html") + XCTAssertFalse(String(describing: observation).contains("secret-id")) + } + + func test_popup_signature_and_authorization_context_are_independent() { + let popup = BrowserDiagnostics.makeAccessibilityObservation(title: "iCloud Passwords", nodes: [DiagnosticNode(role: "AXStaticText", text: "chrome-extension://id/page_popup.html")]) + XCTAssertTrue(popup.stablePopupSignature) + XCTAssertFalse(popup.authorizationContextMatches) + + let context = BrowserDiagnostics.makeAccessibilityObservation(title: "iCloud Passwords", nodes: [DiagnosticNode(role: "AXStaticText", text: "自动填充 verification code")]) + XCTAssertFalse(context.stablePopupSignature) + XCTAssertTrue(context.authorizationContextMatches) + } + + func test_observation_description_excludes_raw_combined_body() { + let observation = BrowserDiagnostics.makeAccessibilityObservation(title: "iCloud Passwords", nodes: [DiagnosticNode(role: "AXStaticText", text: "RAW-PRIVATE-AX-BODY")]) + XCTAssertFalse(String(describing: observation).contains("RAW-PRIVATE-AX-BODY")) + } + + func test_code_parser_terms_support_chinese_and_english_without_changing_authorization_behavior() { + XCTAssertTrue(AuthorizationContext.hasAutofillTerms("请使用自动填充")) + XCTAssertTrue(AuthorizationContext.hasAutofillTerms("browser autofill")) + XCTAssertTrue(AuthorizationContext.hasVerificationCodeTerms("输入验证码")) + XCTAssertTrue(AuthorizationContext.hasVerificationCodeTerms("verification code")) + XCTAssertTrue(AuthorizationContext.isApplePasswordAuthorization("自动填充 verification code")) + XCTAssertFalse(AuthorizationContext.isApplePasswordAuthorization("自动填充 only")) + } } From 479172d871c081bee869039acd57474a1972bc45 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:39:48 +0800 Subject: [PATCH 11/39] fix: keep browser diagnostics read only --- .../BrowserDiagnostics.swift | 34 ++++++++++++------- .../BrowserDiagnosticsTests.swift | 23 +++++++++++++ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index c7c636d..7b3f68d 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -146,31 +146,39 @@ public final class BrowserDiagnostics: BrowserDiagnosticsRunning { return DiagnosticAccessibilityWindow(title: title ?? "", extensionURLSummary: DiagnosticRedactor.extensionURLSummary(from: combined), hasExtensionURL: hasURL, hasPopupPath: popup, hasICloudIdentity: icloud, hasAutofillTerms: AuthorizationContext.hasAutofillTerms(all), hasVerificationCodeTerms: AuthorizationContext.hasVerificationCodeTerms(all), roleCounts: roles, stablePopupSignature: AuthorizationContext.isBrowserExtensionPopup(title: title, text: combined), authorizationContextMatches: AuthorizationContext.isBrowserExtensionAuthorization(title: title, text: combined)) } + static func makeWindowObservation(info: [String: Any], expectedPID: pid_t) -> DiagnosticWindow? { + guard let owner = info[kCGWindowOwnerPID as String] as? NSNumber, + owner.int32Value == Int32(expectedPID), + let numValue = info[kCGWindowNumber as String] as? NSNumber, + let bounds = info[kCGWindowBounds as String] as? [String: Any], + let rect = CGRect(dictionaryRepresentation: bounds as CFDictionary) else { return nil } + let raw = info[kCGWindowName as String] as? String + return DiagnosticWindow(number: numValue.uint32Value, width: Int(rect.width), height: Int(rect.height), title: raw ?? "", titleMatches: AuthorizationContext.isICloudPasswordWindowTitle(raw)) + } + func run(policy: ApplicationRulePolicy, configuredApplications: [TargetApplication], now: Date) -> BrowserDiagnosticReport { - let apps: [NSRunningApplication] - let targets: [TargetApplication] + let pairs: [(TargetApplication, NSRunningApplication?)] switch policy.mode { - case .allowlist: targets = configuredApplications + case .allowlist: + pairs = configuredApplications.map { target in + (target, NSRunningApplication.runningApplications(withBundleIdentifier: target.bundleIdentifier).first) + } case .denylist: let running = NSWorkspace.shared.runningApplications.filter { $0.activationPolicy == .regular && policy.permits(bundleIdentifier: $0.bundleIdentifier ?? "") } - apps = running.sorted { (($0.bundleIdentifier ?? ""), $0.processIdentifier) < (($1.bundleIdentifier ?? ""), $1.processIdentifier) }.prefix(12).map { $0 } - targets = apps.compactMap { guard let b = $0.bundleIdentifier else { return nil }; return TargetApplication(bundleIdentifier: b, displayName: $0.localizedName ?? b) } + pairs = running.sorted { (($0.bundleIdentifier ?? ""), $0.processIdentifier) < (($1.bundleIdentifier ?? ""), $1.processIdentifier) }.prefix(12).compactMap { app in + guard let b = app.bundleIdentifier else { return nil } + return (TargetApplication(bundleIdentifier: b, displayName: app.localizedName ?? b), app) + } } let cg = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? [] var result: [DiagnosticApplication] = [] - for target in targets { - let app = NSRunningApplication.runningApplications(withBundleIdentifier: target.bundleIdentifier).first + for (target, app) in pairs { let permitted = policy.permits(bundleIdentifier: target.bundleIdentifier) let pid = app?.processIdentifier - let windows = cg.filter { ($0[kCGWindowOwnerPID as String] as? pid_t) == pid }.compactMap { info -> DiagnosticWindow? in - guard let num = info[kCGWindowNumber as String] as? UInt32, let bounds = info[kCGWindowBounds as String] as? [String: Any], let rect = CGRect(dictionaryRepresentation: bounds as CFDictionary) else { return nil } - let raw = info[kCGWindowName as String] as? String - return DiagnosticWindow(number: num, width: Int(rect.width), height: Int(rect.height), title: raw ?? "", titleMatches: AuthorizationContext.isICloudPasswordWindowTitle(raw)) - } + let windows = pid.map { expected in cg.compactMap { Self.makeWindowObservation(info: $0, expectedPID: expected) } } ?? [] var axWindows: [DiagnosticAccessibilityWindow] = [] if let app, permitted { let ax = AXUIElementCreateApplication(app.processIdentifier) - AccessibilityTree.enableEnhancedUserInterface(ax) for w in AccessibilityTree.windows(of: ax) { let nodes = AccessibilityTree.collect(from: w, maxDepth: 12, maxNodes: 800).map { DiagnosticNode(role: $0.role, text: $0.text) } axWindows.append(Self.makeAccessibilityObservation(title: AccessibilityTree.title(of: w), nodes: nodes)) diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index cb7f9a8..1b43e4f 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -133,6 +133,29 @@ final class BrowserDiagnosticsTests: XCTestCase { XCTAssertTrue(context.authorizationContextMatches) } + func test_make_window_observation_parses_number_pid_bounds_and_redacts_title() { + let info: [String: Any] = [ + kCGWindowOwnerPID as String: NSNumber(value: 42), + kCGWindowNumber as String: NSNumber(value: UInt32(99)), + kCGWindowBounds as String: ["X": 0, "Y": 0, "Width": 640, "Height": 480], + kCGWindowName as String: "code 123456" + ] + let result = BrowserDiagnostics.makeWindowObservation(info: info, expectedPID: 42) + XCTAssertEqual(result?.number, 99) + XCTAssertEqual(result?.width, 640) + XCTAssertEqual(result?.height, 480) + XCTAssertTrue(result?.title.contains("") == true) + } + + func test_make_window_observation_rejects_pid_mismatch() { + let info: [String: Any] = [ + kCGWindowOwnerPID as String: NSNumber(value: 7), + kCGWindowNumber as String: NSNumber(value: UInt32(1)), + kCGWindowBounds as String: ["X": 0, "Y": 0, "Width": 1, "Height": 1] + ] + XCTAssertNil(BrowserDiagnostics.makeWindowObservation(info: info, expectedPID: 42)) + } + func test_observation_description_excludes_raw_combined_body() { let observation = BrowserDiagnostics.makeAccessibilityObservation(title: "iCloud Passwords", nodes: [DiagnosticNode(role: "AXStaticText", text: "RAW-PRIVATE-AX-BODY")]) XCTAssertFalse(String(describing: observation).contains("RAW-PRIVATE-AX-BODY")) From 49def5e99c9ae8bd1d049926f5d6417798ffcb16 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:43:19 +0800 Subject: [PATCH 12/39] feat: expose one-shot browser diagnostics --- .../ApplicationRules.swift | 6 ++-- Sources/ApplePasswordBridge/BridgeModel.swift | 30 +++++++++++++++++-- .../BrowserDiagnostics.swift | 16 +++++----- .../BrowserDiagnosticsTests.swift | 24 +++++++++++++++ 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/Sources/ApplePasswordBridge/ApplicationRules.swift b/Sources/ApplePasswordBridge/ApplicationRules.swift index cb986c3..c00dda6 100644 --- a/Sources/ApplePasswordBridge/ApplicationRules.swift +++ b/Sources/ApplePasswordBridge/ApplicationRules.swift @@ -24,7 +24,7 @@ enum FillSpeed: String, CaseIterable, Identifiable { } } -enum ApplicationRuleMode: String, CaseIterable, Identifiable { +enum ApplicationRuleMode: String, CaseIterable, Identifiable, Sendable { case allowlist case denylist @@ -38,7 +38,7 @@ enum ApplicationRuleMode: String, CaseIterable, Identifiable { } } -struct TargetApplication: Codable, Hashable, Identifiable { +struct TargetApplication: Codable, Hashable, Identifiable, Sendable { let bundleIdentifier: String let displayName: String @@ -50,7 +50,7 @@ struct TargetApplication: Codable, Hashable, Identifiable { ) } -struct ApplicationRulePolicy { +struct ApplicationRulePolicy: Sendable { let mode: ApplicationRuleMode let allowlist: Set let denylist: Set diff --git a/Sources/ApplePasswordBridge/BridgeModel.swift b/Sources/ApplePasswordBridge/BridgeModel.swift index 2da2e03..a7ec9be 100644 --- a/Sources/ApplePasswordBridge/BridgeModel.swift +++ b/Sources/ApplePasswordBridge/BridgeModel.swift @@ -27,6 +27,8 @@ final class BridgeModel: ObservableObject { @Published private(set) var launchAtLogin = false @Published private(set) var statusText = "正在启动" @Published private(set) var isWorking = false + @Published private(set) var diagnosticReport: BrowserDiagnosticReport? + @Published private(set) var isDiagnosing = false private enum Keys { static let monitoring = "monitoringEnabled" @@ -40,6 +42,7 @@ final class BridgeModel: ObservableObject { private let codeReader = PasswordCodeReader() private let browserAutofill = BrowserAutofill() + private let browserDiagnostics: any BrowserDiagnosticsRunning private var scanTimer: Timer? private var hotKey: GlobalHotKey? private var currentCode: CapturedCode? @@ -51,7 +54,8 @@ final class BridgeModel: ObservableObject { private var manualRequestPending = false private var started = false - init() { + init(browserDiagnostics: any BrowserDiagnosticsRunning = BrowserDiagnostics(), startAutomatically: Bool = true) { + self.browserDiagnostics = browserDiagnostics UserDefaults.standard.register(defaults: [ Keys.monitoring: true, Keys.automaticFill: true, @@ -76,11 +80,31 @@ final class BridgeModel: ObservableObject { key: Keys.denylistedApplications, fallback: [] ) - Task { @MainActor [weak self] in - self?.start() + if startAutomatically { + Task { @MainActor [weak self] in self?.start() } } } + func runDiagnostics() async { + guard !isDiagnosing else { return } + isDiagnosing = true + defer { isDiagnosing = false } + let policy = applicationPolicy + let applications = activeApplicationRules + let now = Date() + let runner = browserDiagnostics + let report = await Task.detached(priority: .userInitiated) { + runner.run(policy: policy, configuredApplications: applications, now: now) + }.value + diagnosticReport = report + } + + func copyDiagnosticReport() { + guard let report = diagnosticReport else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(report.plainText, forType: .string) + } + func start() { guard !started else { return } started = true diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index 7b3f68d..8cd7d2d 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -2,7 +2,7 @@ import Foundation import AppKit import CoreGraphics -public enum BrowserDiagnosticConclusion: String, Equatable { +public enum BrowserDiagnosticConclusion: String, Equatable, Sendable { case applicationNotRunning = "application_not_running" case applicationRejectedByPolicy = "application_rejected_by_policy" case noVisibleWindows = "no_visible_windows" @@ -56,7 +56,7 @@ public enum DiagnosticRedactor { public static func redactTitle(_ title: String?) -> String { String(redactText(title ?? "").prefix(160)) } } -public struct DiagnosticWindow: Equatable { +public struct DiagnosticWindow: Equatable, Sendable { public let number: UInt32 public let width: Int public let height: Int @@ -71,7 +71,7 @@ public struct DiagnosticWindow: Equatable { } } -public struct DiagnosticAccessibilityWindow: Equatable { +public struct DiagnosticAccessibilityWindow: Equatable, Sendable { public let title: String public let extensionURLSummary: String? public let hasExtensionURL: Bool @@ -97,7 +97,7 @@ public struct DiagnosticAccessibilityWindow: Equatable { public var supportedInputCount: Int { ["AXTextField", "AXTextArea", "AXSecureTextField"].reduce(0) { $0 + (roleCounts[$1] ?? 0) } } } -public struct DiagnosticApplication: Equatable { +public struct DiagnosticApplication: Equatable, Sendable { public let displayName: String public let bundleIdentifier: String public let processIdentifier: pid_t? @@ -116,9 +116,9 @@ public struct DiagnosticApplication: Equatable { } } -struct DiagnosticNode { let role: String; let text: String } +struct DiagnosticNode: Sendable { let role: String; let text: String } -public final class BrowserDiagnostics: BrowserDiagnosticsRunning { +public final class BrowserDiagnostics: BrowserDiagnosticsRunning, @unchecked Sendable { public init() {} public static func evaluate(_ applications: [DiagnosticApplication]) -> BrowserDiagnosticConclusion { guard applications.contains(where: { $0.processIdentifier != nil }) else { return .applicationNotRunning } @@ -191,7 +191,7 @@ public final class BrowserDiagnostics: BrowserDiagnosticsRunning { } } -struct BrowserDiagnosticReport: Equatable { +struct BrowserDiagnosticReport: Equatable, Sendable { public let generatedAt: Date public let ruleMode: ApplicationRuleMode public let applications: [DiagnosticApplication] @@ -214,4 +214,4 @@ struct BrowserDiagnosticReport: Equatable { public var plainText: String { renderPlainText() } } -protocol BrowserDiagnosticsRunning { func run(policy: ApplicationRulePolicy, configuredApplications: [TargetApplication], now: Date) -> BrowserDiagnosticReport } +protocol BrowserDiagnosticsRunning: Sendable { func run(policy: ApplicationRulePolicy, configuredApplications: [TargetApplication], now: Date) -> BrowserDiagnosticReport } diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index 1b43e4f..12d1c3e 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -1,7 +1,31 @@ import XCTest @testable import ApplePasswordBridge +private final class FakeDiagnosticsRunner: BrowserDiagnosticsRunning, @unchecked Sendable { + private let lock = NSLock() + private var reports: [BrowserDiagnosticReport] + init(_ reports: [BrowserDiagnosticReport]) { self.reports = reports } + func run(policy: ApplicationRulePolicy, configuredApplications: [TargetApplication], now: Date) -> BrowserDiagnosticReport { + lock.lock(); defer { lock.unlock() } + return reports.isEmpty ? BrowserDiagnosticReport(generatedAt: now, ruleMode: policy.mode, applications: []) : reports.removeFirst() + } +} + final class BrowserDiagnosticsTests: XCTestCase { + @MainActor + func test_bridge_model_runs_diagnostics_and_replaces_report() async { + let first = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 1), ruleMode: .allowlist, applications: []) + let second = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 2), ruleMode: .denylist, applications: []) + let fake = FakeDiagnosticsRunner([first, second]) + let model = BridgeModel(browserDiagnostics: fake, startAutomatically: false) + XCTAssertFalse(model.isDiagnosing) + await model.runDiagnostics() + XCTAssertEqual(model.diagnosticReport, first) + XCTAssertFalse(model.isDiagnosing) + await model.runDiagnostics() + XCTAssertEqual(model.diagnosticReport, second) + XCTAssertFalse(model.isDiagnosing) + } private func application(running: Bool = true, permitted: Bool = true, windows: [DiagnosticWindow] = [], accessibilityWindows: [DiagnosticAccessibilityWindow] = []) -> DiagnosticApplication { DiagnosticApplication(displayName: "Arc", bundleIdentifier: "company.thebrowser.Browser", processIdentifier: running ? 42 : nil, activationPolicy: "regular", permitted: permitted, windows: windows, accessibilityWindows: accessibilityWindows) } From 775e6eb2c92809be9e7cb775de865f7999e9ff91 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:56:42 +0800 Subject: [PATCH 13/39] fix: make diagnostic tasks cancellation aware --- Sources/ApplePasswordBridge/BridgeModel.swift | 36 +++++++++++-- .../BrowserDiagnostics.swift | 5 +- .../BrowserDiagnosticsTests.swift | 52 +++++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/Sources/ApplePasswordBridge/BridgeModel.swift b/Sources/ApplePasswordBridge/BridgeModel.swift index a7ec9be..bc709b4 100644 --- a/Sources/ApplePasswordBridge/BridgeModel.swift +++ b/Sources/ApplePasswordBridge/BridgeModel.swift @@ -3,6 +3,28 @@ import Combine import ServiceManagement import UniformTypeIdentifiers +private final class DiagnosticsTaskBox: @unchecked Sendable { + private let lock = NSLock() + private var task: Task? + private var cancellationRequested = false + + func set(_ task: Task) { + lock.lock() + self.task = task + let shouldCancel = cancellationRequested + lock.unlock() + if shouldCancel { task.cancel() } + } + + func cancel() { + lock.lock() + cancellationRequested = true + let task = task + lock.unlock() + task?.cancel() + } +} + @MainActor final class BridgeModel: ObservableObject { @Published var monitoringEnabled: Bool { @@ -93,9 +115,17 @@ final class BridgeModel: ObservableObject { let applications = activeApplicationRules let now = Date() let runner = browserDiagnostics - let report = await Task.detached(priority: .userInitiated) { - runner.run(policy: policy, configuredApplications: applications, now: now) - }.value + let handle = DiagnosticsTaskBox() + let report = await withTaskCancellationHandler(operation: { + let task = Task.detached(priority: .userInitiated) { + runner.run(policy: policy, configuredApplications: applications, now: now) + } + handle.set(task) + return await task.value + }, onCancel: { + handle.cancel() + }) + guard !Task.isCancelled else { return } diagnosticReport = report } diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index 8cd7d2d..ef1c8a3 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -118,7 +118,7 @@ public struct DiagnosticApplication: Equatable, Sendable { struct DiagnosticNode: Sendable { let role: String; let text: String } -public final class BrowserDiagnostics: BrowserDiagnosticsRunning, @unchecked Sendable { +public struct BrowserDiagnostics: BrowserDiagnosticsRunning, Sendable { public init() {} public static func evaluate(_ applications: [DiagnosticApplication]) -> BrowserDiagnosticConclusion { guard applications.contains(where: { $0.processIdentifier != nil }) else { return .applicationNotRunning } @@ -157,6 +157,7 @@ public final class BrowserDiagnostics: BrowserDiagnosticsRunning, @unchecked Sen } func run(policy: ApplicationRulePolicy, configuredApplications: [TargetApplication], now: Date) -> BrowserDiagnosticReport { + if Task.isCancelled { return BrowserDiagnosticReport(generatedAt: now, ruleMode: policy.mode, applications: []) } let pairs: [(TargetApplication, NSRunningApplication?)] switch policy.mode { case .allowlist: @@ -173,6 +174,7 @@ public final class BrowserDiagnostics: BrowserDiagnosticsRunning, @unchecked Sen let cg = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? [] var result: [DiagnosticApplication] = [] for (target, app) in pairs { + if Task.isCancelled { break } let permitted = policy.permits(bundleIdentifier: target.bundleIdentifier) let pid = app?.processIdentifier let windows = pid.map { expected in cg.compactMap { Self.makeWindowObservation(info: $0, expectedPID: expected) } } ?? [] @@ -180,6 +182,7 @@ public final class BrowserDiagnostics: BrowserDiagnosticsRunning, @unchecked Sen if let app, permitted { let ax = AXUIElementCreateApplication(app.processIdentifier) for w in AccessibilityTree.windows(of: ax) { + if Task.isCancelled { break } let nodes = AccessibilityTree.collect(from: w, maxDepth: 12, maxNodes: 800).map { DiagnosticNode(role: $0.role, text: $0.text) } axWindows.append(Self.makeAccessibilityObservation(title: AccessibilityTree.title(of: w), nodes: nodes)) } diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index 12d1c3e..b04b95f 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -11,6 +11,26 @@ private final class FakeDiagnosticsRunner: BrowserDiagnosticsRunning, @unchecked } } +private final class BlockingDiagnosticsRunner: BrowserDiagnosticsRunning, @unchecked Sendable { + private let lock = NSLock() + private var released = false + private var calls = 0 + private var cancellationObserved = false + var callCount: Int { lock.lock(); defer { lock.unlock() }; return calls } + var sawCancellation: Bool { lock.lock(); defer { lock.unlock() }; return cancellationObserved } + func release() { lock.lock(); released = true; lock.unlock() } + func run(policy: ApplicationRulePolicy, configuredApplications: [TargetApplication], now: Date) -> BrowserDiagnosticReport { + lock.lock(); calls += 1; lock.unlock() + while true { + if Task.isCancelled { lock.lock(); cancellationObserved = true; lock.unlock(); break } + lock.lock(); let done = released; lock.unlock() + if done { break } + Thread.sleep(forTimeInterval: 0.001) + } + return BrowserDiagnosticReport(generatedAt: now, ruleMode: policy.mode, applications: []) + } +} + final class BrowserDiagnosticsTests: XCTestCase { @MainActor func test_bridge_model_runs_diagnostics_and_replaces_report() async { @@ -26,6 +46,38 @@ final class BrowserDiagnosticsTests: XCTestCase { XCTAssertEqual(model.diagnosticReport, second) XCTAssertFalse(model.isDiagnosing) } + + @MainActor + func test_concurrent_diagnostics_request_is_ignored_until_first_finishes() async { + let fake = BlockingDiagnosticsRunner() + let model = BridgeModel(browserDiagnostics: fake, startAutomatically: false) + let first = Task { @MainActor in await model.runDiagnostics() } + for _ in 0..<200 where fake.callCount == 0 { await Task.yield() } + XCTAssertEqual(fake.callCount, 1) + await model.runDiagnostics() + XCTAssertEqual(fake.callCount, 1) + XCTAssertTrue(model.isDiagnosing) + fake.release() + await first.value + XCTAssertFalse(model.isDiagnosing) + XCTAssertNotNil(model.diagnosticReport) + } + + @MainActor + func test_cancelling_diagnostics_observes_cancellation_and_does_not_publish_report() async { + let fake = BlockingDiagnosticsRunner() + let model = BridgeModel(browserDiagnostics: fake, startAutomatically: false) + let task = Task { @MainActor in await model.runDiagnostics() } + for _ in 0..<200 where fake.callCount == 0 { await Task.yield() } + XCTAssertEqual(fake.callCount, 1) + task.cancel() + for _ in 0..<500 where !fake.sawCancellation { await Task.yield() } + XCTAssertTrue(fake.sawCancellation) + fake.release() + await task.value + XCTAssertFalse(model.isDiagnosing) + XCTAssertNil(model.diagnosticReport) + } private func application(running: Bool = true, permitted: Bool = true, windows: [DiagnosticWindow] = [], accessibilityWindows: [DiagnosticAccessibilityWindow] = []) -> DiagnosticApplication { DiagnosticApplication(displayName: "Arc", bundleIdentifier: "company.thebrowser.Browser", processIdentifier: running ? 42 : nil, activationPolicy: "regular", permitted: permitted, windows: windows, accessibilityWindows: accessibilityWindows) } From 33025679e32cc6b64f29cde04456283efee48f09 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:59:54 +0800 Subject: [PATCH 14/39] feat: show browser diagnostics in menu --- Sources/ApplePasswordBridge/BridgeApp.swift | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Sources/ApplePasswordBridge/BridgeApp.swift b/Sources/ApplePasswordBridge/BridgeApp.swift index 64fa9d2..52790c3 100644 --- a/Sources/ApplePasswordBridge/BridgeApp.swift +++ b/Sources/ApplePasswordBridge/BridgeApp.swift @@ -122,6 +122,40 @@ private struct BridgeMenu: View { } } + Divider() + + VStack(alignment: .leading, spacing: 8) { + Button { + Task { await model.runDiagnostics() } + } label: { + Label( + model.isDiagnosing ? "正在诊断…" : "诊断当前授权窗口", + systemImage: "stethoscope" + ) + .frame(maxWidth: .infinity) + } + .disabled(model.isDiagnosing) + + if let report = model.diagnosticReport { + Text(report.summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .textSelection(.enabled) + + HStack { + Text(report.generatedAt, style: .time) + .font(.caption2) + .foregroundStyle(.secondary) + Spacer() + Button("复制诊断报告", action: model.copyDiagnosticReport) + .controlSize(.small) + } + } + } + + Divider() + VStack(alignment: .leading, spacing: 8) { PermissionRow( title: "辅助功能", From 7465a989e603ed7da7da8585d2f807f6cc6dddaf Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:00:14 +0800 Subject: [PATCH 15/39] docs: design Arc popup fallback --- ...8-04-arc-untitled-popup-fallback-design.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-arc-untitled-popup-fallback-design.md diff --git a/docs/superpowers/specs/2026-08-04-arc-untitled-popup-fallback-design.md b/docs/superpowers/specs/2026-08-04-arc-untitled-popup-fallback-design.md new file mode 100644 index 0000000..b128d01 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-arc-untitled-popup-fallback-design.md @@ -0,0 +1,59 @@ +# Arc Untitled Popup Fallback Design + +## Problem + +Arc exposes its iCloud Passwords authorization popup differently across macOS APIs. In the captured failure, Core Graphics reported an untitled `376×189` popup and a `Control Center` main window, while Accessibility exposed a matching authorization context with six text fields. The current autofill path requires a Core Graphics title containing `iCloud` and `密码` or `password` before it inspects Accessibility, so it rejects Arc before the authoritative evidence is evaluated. + +The diagnostic evaluator repeats the same ordering and reports `window_title_mismatch` even when Accessibility has already recognized the target. + +## Chosen Approach + +Keep the existing title match as the preferred fast path. For each eligible running application, if no titled authorization window exists, admit at most one compact on-screen window as a fallback candidate: + +- width from 200 through 700 points; +- height from 100 through 500 points; +- if multiple titled matches exist, choose the lowest window number; +- if multiple compact windows exist, choose the smallest area and then the lowest window number for deterministic ordering. + +The fallback is only a request to inspect the application's Accessibility tree. It is not sufficient authorization evidence. `locateTarget` must still find either the existing stable popup signature or the existing authorization-context signature, plus at least one supported text input. No Arc bundle-ID special case and no empty-title special case will be added. + +Only one candidate per process is returned. This preserves the existing `WindowIdentity`-based handled/retry bookkeeping and prevents one browser with several windows from being filled repeatedly under different identities. + +## Diagnostic Semantics + +A positive Accessibility target is stronger evidence than a missing Core Graphics title. The evaluator will therefore return: + +1. `target_recognized` when a matching Accessibility window has supported inputs, even if every Core Graphics title mismatches; +2. `input_roles_unrecognized` when a matching Accessibility context exists without supported inputs; +3. `window_title_mismatch` only when no Accessibility target is recognized and no Core Graphics title matches. + +The remaining failure boundaries retain their current meanings. Raw Accessibility text, verification codes, and extension identifiers remain excluded from reports. + +## Data Flow + +1. Resolve running applications permitted by the application policy. +2. Read on-screen Core Graphics windows once. +3. Group usable windows by eligible PID and choose one preferred identity per process: titled match first, compact fallback second. +4. Warm up Accessibility and inspect all AX windows for each selected application. +5. Accept only the existing authorization predicates and supported input roles. +6. Reuse the selected identity for retry suppression and handled-window bookkeeping. + +## Tests + +The regression suite will first reproduce the failure before production changes: + +- candidate selection accepts one Arc fallback from a large `Control Center` window plus an untitled `376×189` popup, selecting the compact window; +- candidate selection still prefers a correctly titled iCloud Passwords window; +- a large untitled browser window alone does not become a candidate; +- multiple windows from one PID yield only one candidate; +- diagnostic evaluation treats mismatched CG titles plus a matching AX context with six inputs as `target_recognized`; +- existing Firefox title-based behavior remains green. + +After automated verification, rebuild the diagnostic application and repeat the same Arc scenario. The expected diagnostic conclusion is `target_recognized`, followed by a real manual-fill check. + +## Non-Goals + +- Replacing Core Graphics identities with private AX-to-CG window APIs. +- Continuously scanning the full Accessibility tree for every permitted browser. +- Adding Arc-specific bundle IDs, localized titles, or fixed window-size equality checks. +- Changing code capture, keyboard event generation, fill timing, or application-rule behavior. From b86f16caa100e00e6efb4705258b32f5b6d6ba20 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:08:46 +0800 Subject: [PATCH 16/39] docs: revise Arc fallback security design --- ...8-04-arc-untitled-popup-fallback-design.md | 75 ++++++++++--------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/docs/superpowers/specs/2026-08-04-arc-untitled-popup-fallback-design.md b/docs/superpowers/specs/2026-08-04-arc-untitled-popup-fallback-design.md index b128d01..bd8340a 100644 --- a/docs/superpowers/specs/2026-08-04-arc-untitled-popup-fallback-design.md +++ b/docs/superpowers/specs/2026-08-04-arc-untitled-popup-fallback-design.md @@ -1,59 +1,60 @@ -# Arc Untitled Popup Fallback Design +# Arc Authorization Origin Diagnostics Design -## Problem +## Decision -Arc exposes its iCloud Passwords authorization popup differently across macOS APIs. In the captured failure, Core Graphics reported an untitled `376×189` popup and a `Control Center` main window, while Accessibility exposed a matching authorization context with six text fields. The current autofill path requires a Core Graphics title containing `iCloud` and `密码` or `password` before it inspects Accessibility, so it rejects Arc before the authoritative evidence is evaluated. +This document supersedes the earlier compact-window fallback proposal. Window dimensions are not a security identity and may change with Arc releases, localization, display scale, and UI layout. No size-based fallback will be added to automatic filling at this stage. -The diagnostic evaluator repeats the same ordering and reports `window_title_mismatch` even when Accessibility has already recognized the target. +The next build is diagnostic-only. It will determine whether Arc exposes a stable extension origin or structural Accessibility attributes that can replace the brittle Core Graphics title requirement without trusting page-controlled text. -## Chosen Approach +## Evidence and Risk -Keep the existing title match as the preferred fast path. For each eligible running application, if no titled authorization window exists, admit at most one compact on-screen window as a fallback candidate: +The captured Arc report showed an untitled `376×189` Core Graphics window, but its Accessibility tree contained an iCloud identity, autofill and verification-code terms, and six text fields. The existing production path never inspected that tree because the Core Graphics title did not contain `iCloud` and `密码` or `password`. -- width from 200 through 700 points; -- height from 100 through 500 points; -- if multiple titled matches exist, choose the lowest window number; -- if multiple compact windows exist, choose the smallest area and then the lowest window number for deterministic ordering. +Simply admitting compact windows would reduce defense in depth. The current content-based Accessibility predicate can theoretically be imitated by a web page inside an allowed browser. A malicious page containing the expected phrases and six inputs must not become trusted merely because its window is small. -The fallback is only a request to inspect the application's Accessibility tree. It is not sufficient authorization evidence. `locateTarget` must still find either the existing stable popup signature or the existing authorization-context signature, plus at least one supported text input. No Arc bundle-ID special case and no empty-title special case will be added. +The Accessibility collector already requests `AXURL`, but it only retains values that cast directly to `String`. macOS may expose that attribute as `URL`, `NSURL`, or `CFURL`; those values are currently discarded, which can explain `extension=nil` in the report. -Only one candidate per process is returned. This preserves the existing `WindowIdentity`-based handled/retry bookkeeping and prevents one browser with several windows from being filled repeatedly under different identities. +## Diagnostic Changes -## Diagnostic Semantics +Extend the read-only Accessibility observation model to capture these signals without changing focus, activation, keyboard events, or clipboard behavior: -A positive Accessibility target is stronger evidence than a missing Core Graphics title. The evaluator will therefore return: +- normalize `AXURL` values supplied as `String`, `URL`, `NSURL`, or `CFURL`; +- retain only a redacted extension URL summary: scheme plus path, with host, query, and fragment removed; +- capture the AX window role and subrole; +- capture whether `AXModal`, `AXMain`, and `AXFocused` are present and true; +- continue reporting role counts, existing authorization flags, and supported input count; +- never retain or render raw AX body text, extension identifiers, query values, verification codes, or input values. -1. `target_recognized` when a matching Accessibility window has supported inputs, even if every Core Graphics title mismatches; -2. `input_roles_unrecognized` when a matching Accessibility context exists without supported inputs; -3. `window_title_mismatch` only when no Accessibility target is recognized and no Core Graphics title matches. +URL normalization must be exposed as a small pure helper so each supported representation can be unit tested. Production authorization matching will not consume the newly normalized URL during this diagnostic phase. -The remaining failure boundaries retain their current meanings. Raw Accessibility text, verification codes, and extension identifiers remain excluded from reports. +## Data Flow and Isolation -## Data Flow +`BrowserDiagnostics` will read the additional attributes from each observed AX window and node. The diagnostic model will store only normalized booleans, role/subrole strings, and the already-redacted extension summary. `BrowserAutofill.authorizationWindowCandidates`, `locateTarget`, fill timing, retry bookkeeping, and application rules remain unchanged. -1. Resolve running applications permitted by the application policy. -2. Read on-screen Core Graphics windows once. -3. Group usable windows by eligible PID and choose one preferred identity per process: titled match first, compact fallback second. -4. Warm up Accessibility and inspect all AX windows for each selected application. -5. Accept only the existing authorization predicates and supported input roles. -6. Reuse the selected identity for retry suppression and handled-window bookkeeping. +The diagnostic conclusion ordering remains unchanged in this phase. The new attributes explain why AX recognized the authorization context despite a CG title mismatch, but the result is not used to authorize filling. ## Tests -The regression suite will first reproduce the failure before production changes: +Add failing tests before implementation for: -- candidate selection accepts one Arc fallback from a large `Control Center` window plus an untitled `376×189` popup, selecting the compact window; -- candidate selection still prefers a correctly titled iCloud Passwords window; -- a large untitled browser window alone does not become a candidate; -- multiple windows from one PID yield only one candidate; -- diagnostic evaluation treats mismatched CG titles plus a matching AX context with six inputs as `target_recognized`; -- existing Firefox title-based behavior remains green. +- `AXURL` normalization from `String`, `URL`, and `NSURL` values; +- extension host, query, and fragment removal while preserving scheme and path; +- window role/subrole and modal/main/focused flags in deterministic report output; +- absence of raw URL hosts, six-digit codes, and raw AX text in stored observations and rendered reports; +- no changes to existing BrowserAutofill candidate-selection behavior. -After automated verification, rebuild the diagnostic application and repeat the same Arc scenario. The expected diagnostic conclusion is `target_recognized`, followed by a real manual-fill check. +Run the full test suite and release build, package a new diagnostic application, and repeat the same Arc scenario. + +## Decision Gate After the New Report + +If Arc exposes a stable `chrome-extension://` URL with `/page_popup.html`, the subsequent production design can require that origin signal plus supported inputs as the fallback authorization predicate. Window size may be used only to reduce scanning work, never to establish trust. + +If Arc exposes no stable origin URL, do not weaken authorization based on dimensions or page text alone. Use the newly captured role, subrole, modal, main, and focused evidence to design another narrowly scoped diagnostic or seek a stronger platform identity signal before changing automatic filling. ## Non-Goals -- Replacing Core Graphics identities with private AX-to-CG window APIs. -- Continuously scanning the full Accessibility tree for every permitted browser. -- Adding Arc-specific bundle IDs, localized titles, or fixed window-size equality checks. -- Changing code capture, keyboard event generation, fill timing, or application-rule behavior. +- Enabling Arc automatic filling in this diagnostic-only change. +- Trusting window dimensions, empty titles, or Arc-specific bundle identifiers. +- Using private AX-to-CG window APIs. +- Recording raw Accessibility content or password-extension identifiers. +- Changing code capture, keyboard event generation, application focus, or fill timing. From 972d8bbebd74b40b6de4720de748d0bd3c376d01 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:13:20 +0800 Subject: [PATCH 17/39] docs: plan Arc origin diagnostics --- .../2026-08-04-arc-origin-diagnostics.md | 371 ++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-arc-origin-diagnostics.md diff --git a/docs/superpowers/plans/2026-08-04-arc-origin-diagnostics.md b/docs/superpowers/plans/2026-08-04-arc-origin-diagnostics.md new file mode 100644 index 0000000..bb2d72b --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-arc-origin-diagnostics.md @@ -0,0 +1,371 @@ +# Arc Authorization Origin Diagnostics Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Produce a diagnostic-only build that correctly observes Arc extension URL values and non-content AX window structure without weakening automatic-fill authorization. + +**Architecture:** Add a pure Accessibility value normalizer and retain normalized URLs in a diagnostic-only `AccessibilityNode` field while preserving the existing production text path. Extend diagnostic observations with redacted origin and window metadata, then wire the live collector to those read-only attributes. No candidate discovery, focus, event, retry, or fill behavior changes. + +**Tech Stack:** Swift 5.10, AppKit/ApplicationServices Accessibility APIs, Core Foundation URL bridging, XCTest, SwiftPM, existing deterministic redaction/reporting. + +--- + +## File Map + +- `Sources/ApplePasswordBridge/Accessibility.swift`: normalize heterogeneous AX URL values and expose read-only window metadata helpers; preserve existing production text collection. +- `Sources/ApplePasswordBridge/BrowserDiagnostics.swift`: store only redacted origin summaries and structural flags in diagnostic observations and reports; wire live collection. +- `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift`: prove URL representation support, redaction, structure rendering, and no raw sensitive data retention. +- `dist/Password-Bridge-Arc-Origin-Diagnostics.zip`: ignored build artifact produced only after verification. + +### Task 1: Normalize AX URL values without changing production matching + +**Files:** +- Modify: `Sources/ApplePasswordBridge/Accessibility.swift:4-76` +- Test: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Write failing URL normalization tests** + +Add tests for all representations promised by the design: + +```swift +func test_accessibility_url_normalizer_accepts_string_url_nsurl_and_cfurl() { + let raw = "chrome-extension://secret-id/page_popup.html?popupWindow=42#token" + let foundationURL = URL(string: raw)! + let nsURL = foundationURL as NSURL + let cfURL = CFURLCreateWithString(nil, raw as CFString, nil)! + + XCTAssertEqual(AccessibilityValueNormalizer.urlString(from: raw), raw) + XCTAssertEqual(AccessibilityValueNormalizer.urlString(from: foundationURL), raw) + XCTAssertEqual(AccessibilityValueNormalizer.urlString(from: nsURL), raw) + XCTAssertEqual(AccessibilityValueNormalizer.urlString(from: cfURL), raw) + XCTAssertNil(AccessibilityValueNormalizer.urlString(from: NSNumber(value: 42))) +} +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-arc/cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-arc/cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-arc/cache/swiftpm \ +swift test --disable-sandbox --scratch-path /tmp/apple-password-bridge-arc/build \ + --filter BrowserDiagnosticsTests/test_accessibility_url_normalizer_accepts_string_url_nsurl_and_cfurl +``` + +Expected: compilation fails because `AccessibilityValueNormalizer` does not exist. This is the missing behavior under test, not a test typo. + +- [ ] **Step 3: Add the minimal pure normalizer** + +Add beside `AccessibilityNode`: + +```swift +enum AccessibilityValueNormalizer { + static func urlString(from value: Any?) -> String? { + if let string = value as? String { return string } + if let url = value as? URL { return url.absoluteString } + if let url = value as? NSURL { return url.absoluteString } + if let url = value as? CFURL { return (url as URL).absoluteString } + return nil + } +} +``` + +If Swift bridging makes one URL case subsume another, keep the explicit tests and use the smallest warning-free implementation that passes all four assertions. + +- [ ] **Step 4: Add a diagnostic-only node URL field** + +Extend the node without changing how `text` is built: + +```swift +struct AccessibilityNode { + let element: AXUIElement + let role: String + let text: String + let value: String? + let position: CGPoint? + let diagnosticURL: String? +} +``` + +In `collect`, read `AXURL` separately and normalize it: + +```swift +let rawURL = copy(element, attribute: "AXURL" as CFString) +let diagnosticURL = AccessibilityValueNormalizer.urlString(from: rawURL) +``` + +Pass `diagnosticURL` to the node initializer, but leave `textAttributes`, `values`, and `text` unchanged. This preserves the production authorization input byte-for-byte while exposing the value only to diagnostics. + +- [ ] **Step 5: Verify GREEN and run the full suite** + +Run the focused command from Step 2, then: + +```bash +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-arc/cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-arc/cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-arc/cache/swiftpm \ +swift test --disable-sandbox --scratch-path /tmp/apple-password-bridge-arc/build +``` + +Expected: the focused test passes and the complete suite has zero failures. Existing Firefox/context tests must remain unchanged and green. + +- [ ] **Step 6: Commit URL normalization** + +```bash +git add Sources/ApplePasswordBridge/Accessibility.swift Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: observe accessibility URLs for diagnostics" +``` + +### Task 2: Model and render redacted AX window structure + +**Files:** +- Modify: `Sources/ApplePasswordBridge/BrowserDiagnostics.swift:74-98,119-147,194-215` +- Test: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Write failing observation and report tests** + +Extend the test-only `DiagnosticNode` construction so the raw URL is separate from body text, then assert the stored model contains only a redacted summary: + +```swift +func test_accessibility_observation_redacts_normalized_node_url_and_keeps_structure() { + let observation = BrowserDiagnostics.makeAccessibilityObservation( + title: nil, + role: "AXWindow", + subrole: "AXDialog", + isModal: true, + isMain: false, + isFocused: true, + nodes: [ + DiagnosticNode( + role: "AXWebArea", + text: "RAW-PRIVATE-BODY", + diagnosticURL: "chrome-extension://secret-id/page_popup.html?popupWindow=42#token" + ), + DiagnosticNode(role: "AXTextField", text: "", diagnosticURL: nil) + ] + ) + + XCTAssertEqual(observation.extensionURLSummary, "chrome-extension:///page_popup.html") + XCTAssertEqual(observation.role, "AXWindow") + XCTAssertEqual(observation.subrole, "AXDialog") + XCTAssertTrue(observation.isModal) + XCTAssertFalse(observation.isMain) + XCTAssertTrue(observation.isFocused) + XCTAssertFalse(String(describing: observation).contains("secret-id")) + XCTAssertFalse(String(describing: observation).contains("RAW-PRIVATE-BODY")) +} +``` + +Add a deterministic report assertion: + +```swift +XCTAssertTrue(report.plainText.contains( + "windowRole=AXWindow windowSubrole=AXDialog modal=true main=false focused=true" +)) +XCTAssertFalse(report.plainText.contains("secret-id")) +XCTAssertFalse(report.plainText.contains("popupWindow=42")) +``` + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: + +```bash +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-arc/cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-arc/cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-arc/cache/swiftpm \ +swift test --disable-sandbox --scratch-path /tmp/apple-password-bridge-arc/build \ + --filter BrowserDiagnosticsTests/test_accessibility_observation_redacts_normalized_node_url_and_keeps_structure +``` + +Expected: compilation fails because the diagnostic node, observation initializer, and structural properties do not exist yet. + +- [ ] **Step 3: Extend the diagnostic-only value types** + +Use defaults on the observation initializer so unrelated evaluator tests stay concise: + +```swift +public let role: String +public let subrole: String +public let isModal: Bool +public let isMain: Bool +public let isFocused: Bool +``` + +Extend `DiagnosticNode`: + +```swift +struct DiagnosticNode: Sendable { + let role: String + let text: String + let diagnosticURL: String? + + init(role: String, text: String, diagnosticURL: String? = nil) { + self.role = role + self.text = text + self.diagnosticURL = diagnosticURL + } +} +``` + +In `makeAccessibilityObservation`, derive the extension summary from node URLs first, then fall back to the existing combined text. Pass only the result through `DiagnosticAccessibilityWindow`, whose initializer already redacts extension URLs at capture time: + +```swift +let rawExtensionURL = nodes.compactMap(\.diagnosticURL).first { + DiagnosticRedactor.extensionURLSummary(from: $0) != nil +} +let extensionSource = rawExtensionURL ?? combined +``` + +Do not store `nodes`, `combined`, or `rawExtensionURL` in the returned observation. + +- [ ] **Step 4: Render structural metadata deterministically** + +Extend the existing AX report line with: + +```swift +"windowRole=\(ax.role) windowSubrole=\(ax.subrole) modal=\(ax.isModal) main=\(ax.isMain) focused=\(ax.isFocused)" +``` + +Keep the current sorted application/window ordering and role-count ordering. Run the existing redaction tests to prove the host, query, code, and raw body remain absent. + +- [ ] **Step 5: Verify GREEN and the complete suite** + +Run the focused test from Step 2, then the full test command from Task 1 Step 5. + +Expected: all tests pass with zero failures and no new Swift concurrency warnings. + +- [ ] **Step 6: Commit the diagnostic model** + +```bash +git add Sources/ApplePasswordBridge/BrowserDiagnostics.swift Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: report accessibility window structure" +``` + +### Task 3: Wire live read-only AX metadata and package the enhanced diagnostic build + +**Files:** +- Modify: `Sources/ApplePasswordBridge/Accessibility.swift` +- Modify: `Sources/ApplePasswordBridge/BrowserDiagnostics.swift:159-193` +- Test: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` +- Create ignored artifact: `dist/Password-Bridge-Arc-Origin-Diagnostics.zip` + +- [ ] **Step 1: Write failing pure metadata tests** + +Add a small pure boolean normalizer test before wiring live AX elements: + +```swift +func test_accessibility_boolean_normalizer_accepts_cfboolean_and_nsnumber() { + XCTAssertEqual(AccessibilityValueNormalizer.bool(from: kCFBooleanTrue), true) + XCTAssertEqual(AccessibilityValueNormalizer.bool(from: kCFBooleanFalse), false) + XCTAssertEqual(AccessibilityValueNormalizer.bool(from: NSNumber(value: true)), true) + XCTAssertNil(AccessibilityValueNormalizer.bool(from: "true")) +} +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run the Task 1 focused command with this test name. + +Expected: compilation fails because `AccessibilityValueNormalizer.bool(from:)` does not exist. + +- [ ] **Step 3: Implement read-only metadata access** + +Add the pure boolean helper: + +```swift +static func bool(from value: Any?) -> Bool? { + guard let number = value as? NSNumber else { return nil } + return number.boolValue +} +``` + +Expose focused read-only accessors on `AccessibilityTree`; each calls the existing `copy` helper and never calls `AXUIElementSetAttributeValue`: + +```swift +static func role(of element: AXUIElement) -> String { + copy(element, attribute: kAXRoleAttribute as CFString) as? String ?? "" +} + +static func subrole(of element: AXUIElement) -> String { + copy(element, attribute: kAXSubroleAttribute as CFString) as? String ?? "" +} + +static func bool(_ element: AXUIElement, attribute: CFString) -> Bool { + AccessibilityValueNormalizer.bool(from: copy(element, attribute: attribute)) ?? false +} +``` + +- [ ] **Step 4: Wire the live collector** + +When mapping `AccessibilityNode` to `DiagnosticNode`, pass `diagnosticURL`. When building an observation, pass: + +```swift +role: AccessibilityTree.role(of: window), +subrole: AccessibilityTree.subrole(of: window), +isModal: AccessibilityTree.bool(window, attribute: kAXModalAttribute as CFString), +isMain: AccessibilityTree.bool(window, attribute: kAXMainAttribute as CFString), +isFocused: AccessibilityTree.bool(window, attribute: kAXFocusedAttribute as CFString) +``` + +Confirm the collector still contains no calls to focus, raise, activate, CGEvent posting, pasteboard APIs, file writes, logging, or network APIs. + +- [ ] **Step 5: Run complete verification** + +Run: + +```bash +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-arc/final-cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-arc/final-cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-arc/final-cache/swiftpm \ +swift test --disable-sandbox --scratch-path /tmp/apple-password-bridge-arc/final-build + +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-arc/final-cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-arc/final-cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-arc/final-cache/swiftpm \ +swift build -c release --disable-sandbox --scratch-path /tmp/apple-password-bridge-arc/final-build +``` + +Expected: all tests pass and the release build exits zero. + +- [ ] **Step 6: Commit live diagnostic wiring** + +```bash +git add Sources/ApplePasswordBridge/Accessibility.swift Sources/ApplePasswordBridge/BrowserDiagnostics.swift Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: collect Arc authorization origin evidence" +``` + +- [ ] **Step 7: Package and verify** + +Run `make app`. Because the repository is under a File Provider-managed Documents directory, copy the resulting app without extended attributes, verify it, archive it, extract it, and verify the extracted copy: + +```bash +origin_package_dir=$(mktemp -d /private/tmp/apple-password-bridge-origin.XXXXXX) +ditto --norsrc --noextattr --noqtn --noacl \ + "dist/Password Bridge.app" "$origin_package_dir/Password Bridge.app" +codesign --verify --deep --strict "$origin_package_dir/Password Bridge.app" + +ditto -c -k --norsrc --noextattr --noqtn --noacl --keepParent \ + "$origin_package_dir/Password Bridge.app" \ + "dist/Password-Bridge-Arc-Origin-Diagnostics.zip" + +origin_verify_dir=$(mktemp -d /private/tmp/apple-password-bridge-origin-verify.XXXXXX) +ditto -x -k "dist/Password-Bridge-Arc-Origin-Diagnostics.zip" "$origin_verify_dir" +codesign --verify --deep --strict "$origin_verify_dir/Password Bridge.app" +``` + +Expected: both `codesign` commands exit zero. + +- [ ] **Step 8: Repeat Arc evidence collection** + +Launch the verified app, keep the same Arc authorization popup visible, click `诊断当前授权窗口`, copy the report, and check: + +- whether `extension=` now contains `chrome-extension:///page_popup.html`; +- the window role and subrole; +- modal/main/focused flags; +- six supported text inputs remain visible; +- no extension host, query, verification code, input value, or raw AX body is present. + +Do not modify automatic-fill candidate discovery until this report is reviewed. From 666785e7b38d2dde90109625d0b65ad141bd3ec8 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:35:25 +0800 Subject: [PATCH 18/39] feat: observe accessibility URLs for diagnostics --- Sources/ApplePasswordBridge/Accessibility.swift | 14 ++++++++++++++ .../BrowserDiagnosticsTests.swift | 12 ++++++++++++ 2 files changed, 26 insertions(+) diff --git a/Sources/ApplePasswordBridge/Accessibility.swift b/Sources/ApplePasswordBridge/Accessibility.swift index 494b362..675d5ea 100644 --- a/Sources/ApplePasswordBridge/Accessibility.swift +++ b/Sources/ApplePasswordBridge/Accessibility.swift @@ -1,11 +1,21 @@ import AppKit import ApplicationServices +enum AccessibilityValueNormalizer { + static func urlString(from value: Any?) -> String? { + if let string = value as? String { return string } + if let url = value as? URL { return url.absoluteString } + if let url = value as? NSURL { return url.absoluteString } + return nil + } +} + struct AccessibilityNode { let element: AXUIElement let role: String let text: String let value: String? + let diagnosticURL: String? let position: CGPoint? var isTextInput: Bool { @@ -58,11 +68,15 @@ enum AccessibilityTree { let role = (copy(element, attribute: kAXRoleAttribute as CFString) as? String) ?? "" let value = copy(element, attribute: kAXValueAttribute as CFString) as? String let values = textAttributes.compactMap { copy(element, attribute: $0) as? String } + let diagnosticURL = AccessibilityValueNormalizer.urlString( + from: copy(element, attribute: "AXURL" as CFString) + ) result.append(AccessibilityNode( element: element, role: role, text: values.joined(separator: "\n"), value: value, + diagnosticURL: diagnosticURL, position: point(element, attribute: kAXPositionAttribute as CFString) )) diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index b04b95f..1f2bc59 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -32,6 +32,18 @@ private final class BlockingDiagnosticsRunner: BrowserDiagnosticsRunning, @unche } final class BrowserDiagnosticsTests: XCTestCase { + func test_accessibility_url_normalizer_accepts_string_url_nsurl_and_cfurl() { + let raw = "chrome-extension://secret-id/page_popup.html?popupWindow=42#token" + let foundationURL = URL(string: raw)! + let nsURL = foundationURL as NSURL + let cfURL = CFURLCreateWithString(nil, raw as CFString, nil)! + XCTAssertEqual(AccessibilityValueNormalizer.urlString(from: raw), raw) + XCTAssertEqual(AccessibilityValueNormalizer.urlString(from: foundationURL), raw) + XCTAssertEqual(AccessibilityValueNormalizer.urlString(from: nsURL), raw) + XCTAssertEqual(AccessibilityValueNormalizer.urlString(from: cfURL), raw) + XCTAssertNil(AccessibilityValueNormalizer.urlString(from: NSNumber(value: 42))) + } + @MainActor func test_bridge_model_runs_diagnostics_and_replaces_report() async { let first = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 1), ruleMode: .allowlist, applications: []) From 0bd591952f48ecb7e7c415754e9d90bae8bca2b2 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:47:00 +0800 Subject: [PATCH 19/39] feat: report accessibility window structure --- .../BrowserDiagnostics.swift | 43 +++++++++++++++---- .../BrowserDiagnosticsTests.swift | 31 ++++++++++++- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index ef1c8a3..f7f9de9 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -73,6 +73,11 @@ public struct DiagnosticWindow: Equatable, Sendable { public struct DiagnosticAccessibilityWindow: Equatable, Sendable { public let title: String + public let role: String + public let subrole: String + public let isModal: Bool + public let isMain: Bool + public let isFocused: Bool public let extensionURLSummary: String? public let hasExtensionURL: Bool public let hasPopupPath: Bool @@ -82,8 +87,9 @@ public struct DiagnosticAccessibilityWindow: Equatable, Sendable { public let roleCounts: [String: Int] public let stablePopupSignature: Bool public let authorizationContextMatches: Bool - public init(title: String, extensionURLSummary: String?, hasExtensionURL: Bool, hasPopupPath: Bool, hasICloudIdentity: Bool, hasAutofillTerms: Bool, hasVerificationCodeTerms: Bool, roleCounts: [String: Int], stablePopupSignature: Bool, authorizationContextMatches: Bool) { + public init(title: String, extensionURLSummary: String?, hasExtensionURL: Bool, hasPopupPath: Bool, hasICloudIdentity: Bool, hasAutofillTerms: Bool, hasVerificationCodeTerms: Bool, roleCounts: [String: Int], stablePopupSignature: Bool, authorizationContextMatches: Bool, role: String = "", subrole: String = "", isModal: Bool = false, isMain: Bool = false, isFocused: Bool = false) { self.title = DiagnosticRedactor.redactTitle(title) + self.role = role; self.subrole = subrole; self.isModal = isModal; self.isMain = isMain; self.isFocused = isFocused self.extensionURLSummary = extensionURLSummary.flatMap { DiagnosticRedactor.extensionURLSummary(from: $0) } self.hasExtensionURL = hasExtensionURL self.hasPopupPath = hasPopupPath @@ -116,7 +122,9 @@ public struct DiagnosticApplication: Equatable, Sendable { } } -struct DiagnosticNode: Sendable { let role: String; let text: String } +struct DiagnosticNode: Sendable { let role: String; let text: String; let diagnosticURL: String? + init(role: String, text: String, diagnosticURL: String? = nil) { self.role = role; self.text = text; self.diagnosticURL = diagnosticURL } +} public struct BrowserDiagnostics: BrowserDiagnosticsRunning, Sendable { public init() {} @@ -134,16 +142,21 @@ public struct BrowserDiagnostics: BrowserDiagnosticsRunning, Sendable { return .targetRecognized } - static func makeAccessibilityObservation(title: String?, nodes: [DiagnosticNode]) -> DiagnosticAccessibilityWindow { + static func makeAccessibilityObservation(title: String?, nodes: [DiagnosticNode], role: String = "", subrole: String = "", isModal: Bool = false, isMain: Bool = false, isFocused: Bool = false) -> DiagnosticAccessibilityWindow { let combined = nodes.map(\.text).joined(separator: "\n") let all = [title ?? "", combined].joined(separator: "\n") let lower = all.lowercased() - let hasURL = lower.contains("chrome-extension://") || lower.contains("moz-extension://") - let popup = hasURL && lower.contains("/page_popup.html") + let rawDiagnosticURLs = nodes.compactMap(\.diagnosticURL) + let diagnosticURLSummary = rawDiagnosticURLs.compactMap { DiagnosticRedactor.extensionURLSummary(from: $0) }.first + let hasURL = diagnosticURLSummary != nil || lower.contains("chrome-extension://") || lower.contains("moz-extension://") + let popup = hasURL && (lower.contains("/page_popup.html") || rawDiagnosticURLs.contains { $0.lowercased().contains("/page_popup.html") }) let icloud = AuthorizationContext.isICloudPasswordWindowTitle(title) || lower.contains("icloud 密码") || lower.contains("icloud passwords") var roles: [String: Int] = [:] for n in nodes { roles[n.role, default: 0] += 1 } - return DiagnosticAccessibilityWindow(title: title ?? "", extensionURLSummary: DiagnosticRedactor.extensionURLSummary(from: combined), hasExtensionURL: hasURL, hasPopupPath: popup, hasICloudIdentity: icloud, hasAutofillTerms: AuthorizationContext.hasAutofillTerms(all), hasVerificationCodeTerms: AuthorizationContext.hasVerificationCodeTerms(all), roleCounts: roles, stablePopupSignature: AuthorizationContext.isBrowserExtensionPopup(title: title, text: combined), authorizationContextMatches: AuthorizationContext.isBrowserExtensionAuthorization(title: title, text: combined)) + return DiagnosticAccessibilityWindow(title: title ?? "", extensionURLSummary: diagnosticURLSummary ?? DiagnosticRedactor.extensionURLSummary(from: combined), hasExtensionURL: hasURL, hasPopupPath: popup, hasICloudIdentity: icloud, hasAutofillTerms: AuthorizationContext.hasAutofillTerms(all), hasVerificationCodeTerms: AuthorizationContext.hasVerificationCodeTerms(all), roleCounts: roles, stablePopupSignature: AuthorizationContext.isBrowserExtensionPopup(title: title, text: combined), authorizationContextMatches: AuthorizationContext.isBrowserExtensionAuthorization(title: title, text: combined), role: role, subrole: subrole, isModal: isModal, isMain: isMain, isFocused: isFocused) + } + static func makeAccessibilityObservation(title: String?, role: String, subrole: String, isModal: Bool, isMain: Bool, isFocused: Bool, nodes: [DiagnosticNode]) -> DiagnosticAccessibilityWindow { + makeAccessibilityObservation(title: title, nodes: nodes, role: role, subrole: subrole, isModal: isModal, isMain: isMain, isFocused: isFocused) } static func makeWindowObservation(info: [String: Any], expectedPID: pid_t) -> DiagnosticWindow? { @@ -207,9 +220,23 @@ struct BrowserDiagnosticReport: Equatable, Sendable { let pid = app.processIdentifier.map(String.init) ?? "nil" lines.append("app=\(app.displayName) bundle=\(app.bundleIdentifier) pid=\(pid) activation=\(app.activationPolicy ?? "nil") permitted=\(app.permitted)") for w in app.windows.sorted(by: { $0.number < $1.number }) { lines.append("cgWindow=\(w.number) size=\(w.width)x\(w.height) title=\(DiagnosticRedactor.redactTitle(w.title)) match=\(w.titleMatches)") } - for ax in app.accessibilityWindows.sorted(by: { (DiagnosticRedactor.redactTitle($0.title), $0.extensionURLSummary ?? "") < (DiagnosticRedactor.redactTitle($1.title), $1.extensionURLSummary ?? "") }) { + func accessibilitySortKey(_ ax: DiagnosticAccessibilityWindow) -> [String] { + let roles = ax.roleCounts.keys.sorted().map { "\($0)=\(ax.roleCounts[$0]!)" }.joined(separator: ", ") + func bit(_ value: Bool) -> String { value ? "1" : "0" } + return [ + DiagnosticRedactor.redactTitle(ax.title), ax.extensionURLSummary ?? "", ax.role, ax.subrole, + bit(ax.isModal), bit(ax.isMain), bit(ax.isFocused), bit(ax.hasExtensionURL), bit(ax.hasPopupPath), + bit(ax.hasICloudIdentity), bit(ax.hasAutofillTerms), bit(ax.hasVerificationCodeTerms), roles, + bit(ax.stablePopupSignature), bit(ax.authorizationContextMatches) + ] + } + for ax in app.accessibilityWindows.sorted(by: { + let lhs = accessibilitySortKey($0), rhs = accessibilitySortKey($1) + for (a, b) in zip(lhs, rhs) where a != b { return a < b } + return false + }) { let roles = ax.roleCounts.keys.sorted().map { "\($0)=\(ax.roleCounts[$0]!)" }.joined(separator: ", ") - lines.append("axTitle=\(DiagnosticRedactor.redactTitle(ax.title)) extension=\(ax.extensionURLSummary ?? "nil") flags=\(ax.hasExtensionURL),\(ax.hasPopupPath),\(ax.hasICloudIdentity),\(ax.hasAutofillTerms),\(ax.hasVerificationCodeTerms) roles=\(roles) existingMatch=\(ax.stablePopupSignature || ax.authorizationContextMatches)") + lines.append("axTitle=\(DiagnosticRedactor.redactTitle(ax.title)) extension=\(ax.extensionURLSummary ?? "nil") flags=\(ax.hasExtensionURL),\(ax.hasPopupPath),\(ax.hasICloudIdentity),\(ax.hasAutofillTerms),\(ax.hasVerificationCodeTerms) roles=\(roles) windowRole=\(ax.role) windowSubrole=\(ax.subrole) modal=\(ax.isModal) main=\(ax.isMain) focused=\(ax.isFocused) existingMatch=\(ax.stablePopupSignature || ax.authorizationContextMatches)") } } return lines.joined(separator: "\n") diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index 1f2bc59..2fcc895 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -178,11 +178,23 @@ final class BrowserDiagnosticsTests: XCTestCase { } func test_report_plain_text_is_deterministic_and_redacted() { - let ax = DiagnosticAccessibilityWindow(title: "AX 123456", extensionURLSummary: "chrome-extension://abcdefghijklmnop/page_popup.html?popupWindow=42", hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: true, hasAutofillTerms: true, hasVerificationCodeTerms: true, roleCounts: ["AXButton": 2, "AXTextField": 1], stablePopupSignature: true, authorizationContextMatches: true) + let ax = BrowserDiagnostics.makeAccessibilityObservation(title: "AX 123456", role: "AXWindow", subrole: "AXDialog", isModal: true, isMain: false, isFocused: true, nodes: [DiagnosticNode(role: "AXWebArea", text: "RAW-PRIVATE-BODY", diagnosticURL: "chrome-extension://abcdefghijklmnop/page_popup.html?popupWindow=42"), DiagnosticNode(role: "AXButton", text: ""), DiagnosticNode(role: "AXButton", text: ""), DiagnosticNode(role: "AXTextField", text: "")]) let app = DiagnosticApplication(displayName: "Arc", bundleIdentifier: "company.thebrowser.Browser", processIdentifier: 42, activationPolicy: "regular", permitted: true, windows: [DiagnosticWindow(number: 7, width: 800, height: 600, title: "code 123456", titleMatches: true)], accessibilityWindows: [ax]) let report = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 0), ruleMode: .allowlist, applications: [app]) let text = report.plainText XCTAssertTrue(text.contains("")); XCTAssertFalse(text.contains("123456")); XCTAssertFalse(text.contains("abcdefghijklmnop")); XCTAssertFalse(text.contains("popupWindow=42")); XCTAssertTrue(text.contains("AXButton=2, AXTextField=1")) + XCTAssertTrue(text.contains("windowRole=AXWindow windowSubrole=AXDialog modal=true main=false focused=true")) + XCTAssertFalse(text.contains("RAW-PRIVATE-BODY")) + } + + func test_report_plain_text_sorts_accessibility_windows_by_all_rendered_fields() { + let first = DiagnosticAccessibilityWindow(title: "Same", extensionURLSummary: "chrome-extension://id/path", hasExtensionURL: true, hasPopupPath: false, hasICloudIdentity: false, hasAutofillTerms: true, hasVerificationCodeTerms: false, roleCounts: ["AXButton": 1], stablePopupSignature: false, authorizationContextMatches: true, role: "AXWindow", subrole: "AXDialog", isModal: true, isMain: false, isFocused: false) + let second = DiagnosticAccessibilityWindow(title: "Same", extensionURLSummary: "chrome-extension://id/path", hasExtensionURL: false, hasPopupPath: true, hasICloudIdentity: false, hasAutofillTerms: false, hasVerificationCodeTerms: true, roleCounts: ["AXTextField": 2], stablePopupSignature: true, authorizationContextMatches: false, role: "AXWindow", subrole: "AXSheet", isModal: false, isMain: true, isFocused: true) + let app1 = application(windows: [visible], accessibilityWindows: [first, second]) + let app2 = application(windows: [visible], accessibilityWindows: [second, first]) + let report1 = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 0), ruleMode: .allowlist, applications: [app1]) + let report2 = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 0), ruleMode: .allowlist, applications: [app2]) + XCTAssertEqual(report1.plainText, report2.plainText) } func test_all_conclusions_have_chinese_summary() { @@ -208,9 +220,26 @@ final class BrowserDiagnosticsTests: XCTestCase { XCTAssertEqual(observation.roleCounts["AXStaticText"], 1) XCTAssertEqual(observation.roleCounts["AXTextField"], 1) XCTAssertEqual(observation.extensionURLSummary, "chrome-extension:///page_popup.html") + XCTAssertTrue(observation.hasExtensionURL) + XCTAssertTrue(observation.hasPopupPath) XCTAssertFalse(String(describing: observation).contains("secret-id")) } + func test_accessibility_observation_reports_window_structure_and_prefers_diagnostic_url() { + let observation = BrowserDiagnostics.makeAccessibilityObservation( + title: nil, role: "AXWindow", subrole: "AXDialog", isModal: true, isMain: false, isFocused: true, + nodes: [ + DiagnosticNode(role: "AXWebArea", text: "RAW-PRIVATE-BODY", diagnosticURL: "chrome-extension://secret-id/page_popup.html?popupWindow=42#token"), + DiagnosticNode(role: "AXTextField", text: "", diagnosticURL: nil) + ]) + XCTAssertEqual(observation.extensionURLSummary, "chrome-extension:///page_popup.html") + XCTAssertTrue(observation.hasExtensionURL) + XCTAssertTrue(observation.hasPopupPath) + XCTAssertEqual(observation.role, "AXWindow"); XCTAssertEqual(observation.subrole, "AXDialog") + XCTAssertTrue(observation.isModal); XCTAssertFalse(observation.isMain); XCTAssertTrue(observation.isFocused) + XCTAssertFalse(String(describing: observation).contains("secret-id")); XCTAssertFalse(String(describing: observation).contains("RAW-PRIVATE-BODY")) + } + func test_popup_signature_and_authorization_context_are_independent() { let popup = BrowserDiagnostics.makeAccessibilityObservation(title: "iCloud Passwords", nodes: [DiagnosticNode(role: "AXStaticText", text: "chrome-extension://id/page_popup.html")]) XCTAssertTrue(popup.stablePopupSignature) From 78b511da0ccec1c0575e69841ea321d4624425c8 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:19:15 +0800 Subject: [PATCH 20/39] docs: design origin-gated Arc autofill --- ...-08-04-arc-origin-gated-autofill-design.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-arc-origin-gated-autofill-design.md diff --git a/docs/superpowers/specs/2026-08-04-arc-origin-gated-autofill-design.md b/docs/superpowers/specs/2026-08-04-arc-origin-gated-autofill-design.md new file mode 100644 index 0000000..ff0dc2d --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-arc-origin-gated-autofill-design.md @@ -0,0 +1,63 @@ +# Arc Origin-Gated Autofill Design + +## Goal + +Allow Arc's untitled iCloud Passwords extension popup to reach the existing fill path without trusting a window title, window size, Arc-specific rule, or page-controlled text alone. + +## Evidence + +The live Arc report identified an AX window with all of the following signals: + +- `AXURL` normalized as `chrome-extension:///page_popup.html`; +- iCloud identity, autofill, and verification-code context; +- six `AXTextField` elements; +- no CGWindow or AX window title. + +The current production gate rejects this window before AX inspection because its Core Graphics title is empty. + +## Chosen Design + +Keep the existing Core Graphics title match as the preferred fast path. For each permitted running browser process that has no titled candidate, select one fallback candidate from its topmost on-screen Core Graphics window. This is only a scheduling identity for retry and handled-window bookkeeping; it is not a trust signal. + +The fallback candidate receives the same read-only AX traversal as a titled candidate, but it may return a fill target only if a single AX window satisfies every condition below: + +1. an AX attribute URL, not text scraping, has either the `chrome-extension` or `moz-extension` scheme and a `/page_popup.html` path; +2. existing authorization context recognizes an iCloud/extension identity plus autofill and verification-code terms; +3. the AX window exposes at least six supported text inputs. + +The selected AX window is still the one raised and filled by the existing code. The Core Graphics fallback identity is never used to infer extension origin or select fields. + +## Candidate and Retry Behavior + +Candidate discovery reads Core Graphics windows once per scan. For each eligible PID, it selects the first title-matching window in window-list order; if none matches, it selects that PID's first visible window in the same order and marks it as origin-gated. At most one candidate is emitted per PID. + +Existing `WindowIdentity`, retry backoff, and handled-window storage remain unchanged. Because the fallback identity follows the topmost window, a newly shown popup obtains a new identity and is eligible for an immediate AX check; an unchanged browser window remains governed by the existing retry backoff. No new timer, AX observer, window-size threshold, or private API is introduced. + +## Security Boundary + +The legacy title-matched path retains its current behavior. The new titleless path is stricter: it requires an AXURL-derived extension popup origin, full existing authorization context, and six supported inputs. A window title, empty title, dimensions, visible strings, bundle ID, or role alone cannot authorize automatic filling. + +The implementation must keep raw extension URLs transient. It may use them to test scheme/path in the current AX traversal, but it must not persist raw host, query, fragment, extension identifier, raw AX text, or one-time code in reports, state, logs, or clipboard. + +## Diagnostic Semantics + +After the production predicate exists, the diagnostic evaluator should report `target_recognized` for a permitted application's AX window with trusted extension URL, context, and supported inputs even if every CG title mismatches. `window_title_mismatch` remains the result only when no trusted AX target is present. + +## Tests and Manual Validation + +Automated tests must prove: + +- candidate discovery emits one origin-gated fallback per eligible PID when no titles match and retains title preference when one does; +- a fallback target requires AXURL origin, context, and six inputs; each missing component is rejected; +- title-matched legacy candidates preserve their current acceptance behavior; +- a trusted AX target overrides a title mismatch in diagnostic evaluation; +- fallback candidates keep current retry identity semantics and no production path accepts a size-only or text-only popup. + +Manual validation repeats the captured Arc scenario. Expected results are a `target_recognized` diagnostic and a fill attempt only after the strong origin predicate is present. Recheck Firefox, manual fill, automatic scan, application rules, and permissions after the change. + +## Non-Goals + +- Trusting compact windows, untitled windows, or Arc-specific bundle IDs. +- Removing the legacy title-matched fast path. +- Using private AX-to-CG mapping APIs. +- Changing code capture, keyboard-event generation, fill timing, or permission handling. From c8f41bc2958cb90d2ff3a5a5bbf888e79046f218 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:22:44 +0800 Subject: [PATCH 21/39] docs: plan origin-gated Arc autofill --- .../2026-08-04-arc-origin-gated-autofill.md | 341 ++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-arc-origin-gated-autofill.md diff --git a/docs/superpowers/plans/2026-08-04-arc-origin-gated-autofill.md b/docs/superpowers/plans/2026-08-04-arc-origin-gated-autofill.md new file mode 100644 index 0000000..bf5fc0f --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-arc-origin-gated-autofill.md @@ -0,0 +1,341 @@ +# Arc Origin-Gated Autofill Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow Arc's untitled iCloud Passwords extension popup to use the existing fill path only after a trusted AXURL origin, authorization context, and six input fields are verified. + +**Architecture:** Candidate discovery emits one candidate per permitted browser PID: a title-matched window when present, otherwise the PID's topmost visible window marked origin-gated. Title candidates preserve legacy target matching; origin-gated candidates may succeed only with an AX-attribute `chrome-extension`/`moz-extension` popup URL, existing context, and six inputs. Diagnostics explicitly record whether an extension URL came from AX attributes. + +**Tech Stack:** Swift 5.10, AppKit/ApplicationServices/CoreGraphics, XCTest, SwiftPM. + +--- + +### Task 0: Commit the already-verified origin diagnostic collector + +**Files:** +- Modify already present: `Sources/ApplePasswordBridge/Accessibility.swift` +- Modify already present: `Sources/ApplePasswordBridge/BrowserDiagnostics.swift` +- Modify already present: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Confirm the existing verification evidence** + +Run: + +```bash +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-arc/cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-arc/cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-arc/cache/swiftpm \ +swift test --disable-sandbox --scratch-path /tmp/apple-password-bridge-arc/build +``` + +Expected: 36 tests pass. The uncommitted change is limited to URL/boolean normalization, read-only AX metadata, live diagnostic wiring, and its tests. + +- [ ] **Step 2: Commit the verified collector without mixing production changes** + +```bash +git add Sources/ApplePasswordBridge/Accessibility.swift \ + Sources/ApplePasswordBridge/BrowserDiagnostics.swift \ + Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: collect Arc authorization origin evidence" +``` + +### Task 1: Add pure candidate selection and origin helpers + +**Files:** +- Modify: `Sources/ApplePasswordBridge/FirefoxAutofill.swift:26-75` +- Modify: `Sources/ApplePasswordBridge/CodeParser.swift:26-48` +- Test: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Write failing pure selection and URL-origin tests** + +Add tests around a new internal helper that selects a single `WindowIdentity` plus `requiresTrustedOrigin` per eligible PID from CG dictionaries: + +```swift +func test_candidate_selection_prefers_title_then_topmost_fallback_once_per_pid() { + let pid = pid_t(42) + let windows: [[String: Any]] = [ + [kCGWindowOwnerPID as String: NSNumber(value: pid), + kCGWindowNumber as String: NSNumber(value: UInt32(9)), + kCGWindowName as String: ""], + [kCGWindowOwnerPID as String: NSNumber(value: pid), + kCGWindowNumber as String: NSNumber(value: UInt32(10)), + kCGWindowName as String: "iCloud Passwords"] + ] + + let selected = BrowserAutofill.selectCandidates( + windowInfo: windows, + eligiblePIDs: [pid] + ) + + XCTAssertEqual(selected.count, 1) + XCTAssertEqual(selected[0].identity.windowNumber, 10) + XCTAssertFalse(selected[0].requiresTrustedOrigin) +} + +func test_candidate_selection_uses_topmost_window_as_origin_gated_fallback() { + let pid = pid_t(42) + let selected = BrowserAutofill.selectCandidates( + windowInfo: [[ + kCGWindowOwnerPID as String: NSNumber(value: pid), + kCGWindowNumber as String: NSNumber(value: UInt32(9)), + kCGWindowName as String: "" + ]], + eligiblePIDs: [pid] + ) + + XCTAssertEqual(selected.map(\.identity.windowNumber), [9]) + XCTAssertEqual(selected.map(\.requiresTrustedOrigin), [true]) +} + +func test_extension_popup_origin_requires_extension_scheme_and_popup_path() { + XCTAssertTrue(AuthorizationContext.isBrowserExtensionPopupURL( + "chrome-extension://id/page_popup.html?popupWindow=42" + )) + XCTAssertTrue(AuthorizationContext.isBrowserExtensionPopupURL( + "moz-extension://id/page_popup.html" + )) + XCTAssertFalse(AuthorizationContext.isBrowserExtensionPopupURL( + "https://example.com/page_popup.html" + )) + XCTAssertFalse(AuthorizationContext.isBrowserExtensionPopupURL( + "chrome-extension://id/other.html" + )) +} +``` + +- [ ] **Step 2: Run focused tests and verify RED** + +Run the full SwiftPM command above with `--filter BrowserDiagnosticsTests/test_candidate_selection_prefers_title_then_topmost_fallback_once_per_pid` and then the URL-origin test. + +Expected: compilation fails because `selectCandidates` and `isBrowserExtensionPopupURL` do not yet exist. + +- [ ] **Step 3: Implement pure helpers** + +Add a `CandidateDescriptor` nested in `BrowserAutofill`: + +```swift +struct CandidateDescriptor: Equatable { + let identity: WindowIdentity + let requiresTrustedOrigin: Bool +} +``` + +Implement `selectCandidates(windowInfo:eligiblePIDs:)` by scanning the supplied list once. Ignore entries without numeric PID/window number or a PID outside `eligiblePIDs`. Record the first window for each PID as fallback and the first title-matching window as preferred; after the scan return title-matching descriptor when available, otherwise fallback marked `requiresTrustedOrigin: true`. Sort returned descriptors by their first appearance index, and emit no more than one per PID. + +Add the pure authorization helper: + +```swift +static func isBrowserExtensionPopupURL(_ url: String) -> Bool { + let lower = url.lowercased() + let isExtension = lower.hasPrefix("chrome-extension://") + || lower.hasPrefix("moz-extension://") + return isExtension && lower.contains("/page_popup.html") +} +``` + +- [ ] **Step 4: Verify GREEN and commit** + +Run the focused tests and the complete suite. Expected: all tests pass. + +```bash +git add Sources/ApplePasswordBridge/FirefoxAutofill.swift \ + Sources/ApplePasswordBridge/CodeParser.swift \ + Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: select origin-gated browser candidates" +``` + +### Task 2: Gate fallback fill targets on AXURL origin, context, and six inputs + +**Files:** +- Modify: `Sources/ApplePasswordBridge/FirefoxAutofill.swift:32-125` +- Test: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Write failing target-predicate tests** + +Extract the candidate-dependent AX decision into an internal pure helper accepting `requiresTrustedOrigin`, node URLs, context result, and field count. Add tests: + +```swift +func test_origin_gated_target_requires_url_context_and_six_fields() { + XCTAssertTrue(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: true, + diagnosticURLs: ["chrome-extension://id/page_popup.html"], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 6 + )) + XCTAssertFalse(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: true, + diagnosticURLs: [], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 6 + )) + XCTAssertFalse(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: true, + diagnosticURLs: ["chrome-extension://id/page_popup.html"], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 5 + )) + XCTAssertFalse(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: true, + diagnosticURLs: ["https://example.com/page_popup.html"], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 6 + )) +} + +func test_title_candidate_retains_legacy_target_acceptance() { + XCTAssertTrue(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: false, + diagnosticURLs: [], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 1 + )) +} +``` + +- [ ] **Step 2: Run focused tests and verify RED** + +Run the focused SwiftPM test command for the first test. Expected: compilation fails because `acceptsTarget` does not exist. + +- [ ] **Step 3: Carry origin-gated state into real candidate discovery** + +Extend `Candidate`: + +```swift +let requiresTrustedOrigin: Bool +``` + +Rewrite `authorizationWindowCandidates(policy:)` to build `eligibleApplicationsByPID`, call `selectCandidates(windowInfo:eligiblePIDs:)`, and map descriptors to applications. Preserve exactly one candidate per eligible PID. Do not activate or focus applications in this method. + +- [ ] **Step 4: Implement the pure target gate and use it in locateTarget** + +Implement: + +```swift +static func acceptsTarget( + requiresTrustedOrigin: Bool, + diagnosticURLs: [String], + hasAuthorizationContext: Bool, + hasStablePopupSignature: Bool, + inputCount: Int +) -> Bool { + if requiresTrustedOrigin { + return diagnosticURLs.contains(AuthorizationContext.isBrowserExtensionPopupURL) + && hasAuthorizationContext + && inputCount >= 6 + } + return (hasStablePopupSignature || hasAuthorizationContext) && inputCount > 0 +} +``` + +In `locateTarget`, collect `let diagnosticURLs = nodes.compactMap(\.diagnosticURL)`, calculate the existing context/signature and `fields`, then use `acceptsTarget` with `applicationCandidates[0].requiresTrustedOrigin`. Preserve the existing `inputNotFound` error only after a recognized legacy or trusted-origin context has no inputs. Do not persist or render `diagnosticURLs`. + +- [ ] **Step 5: Verify GREEN and commit** + +Run the focused tests and complete suite. Expected: all tests pass and existing Firefox tests remain green. + +```bash +git add Sources/ApplePasswordBridge/FirefoxAutofill.swift \ + Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: gate untitled popup autofill on extension origin" +``` + +### Task 3: Surface trusted AX origin in diagnostics and adjust conclusion precedence + +**Files:** +- Modify: `Sources/ApplePasswordBridge/BrowserDiagnostics.swift:74-159,225-250` +- Test: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Write failing evaluator/report tests** + +Construct a permitted running application with a mismatched CG title and one AX window containing `hasAccessibilityExtensionURL: true`, `hasPopupPath: true`, `authorizationContextMatches: true`, and six fields. Assert `BrowserDiagnostics.evaluate` is `.targetRecognized`. Construct the same window with `hasAccessibilityExtensionURL: false` and assert `.windowTitleMismatch`. + +Add a report assertion: + +```swift +XCTAssertTrue(report.plainText.contains("axURL=true")) +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run the focused evaluator test. Expected: the current evaluator returns `.windowTitleMismatch` and `hasAccessibilityExtensionURL` is unavailable. + +- [ ] **Step 3: Add source-provenance boolean** + +Add `hasAccessibilityExtensionURL` to `DiagnosticAccessibilityWindow`, defaulting to `false` at the end of its initializer. In `makeAccessibilityObservation`, set it only when a raw `DiagnosticNode.diagnosticURL` itself passes `DiagnosticRedactor.extensionURLSummary`; text-discovered URLs must leave it false. Render it as `axURL=true` or `axURL=false` in the deterministic AX report line and include it in the AX sort key. + +- [ ] **Step 4: Prioritize trusted AX targets in evaluate** + +Before the CG title mismatch guard, compute trusted AX windows: + +```swift +let trusted = permitted.flatMap(\.accessibilityWindows).filter { + $0.hasAccessibilityExtensionURL + && $0.hasPopupPath + && $0.authorizationContextMatches +} +if trusted.contains(where: { $0.supportedInputCount >= 6 }) { + return .targetRecognized +} +``` + +Keep all other current failure boundaries unchanged. A text-only `chrome-extension://` match cannot bypass title mismatch because its provenance boolean is false. + +- [ ] **Step 5: Verify GREEN and commit** + +Run focused evaluator/report tests and the full suite. Expected: all tests pass. + +```bash +git add Sources/ApplePasswordBridge/BrowserDiagnostics.swift \ + Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: recognize trusted untitled extension popups" +``` + +### Task 4: Clean verification and Arc validation + +**Files:** +- Create ignored artifact: `dist/Password-Bridge-Arc-Origin-Gated.zip` + +- [ ] **Step 1: Fresh test and release build** + +```bash +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-arc/gated-cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-arc/gated-cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-arc/gated-cache/swiftpm \ +swift test --disable-sandbox --scratch-path /tmp/apple-password-bridge-arc/gated-build + +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-arc/gated-cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-arc/gated-cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-arc/gated-cache/swiftpm \ +swift build -c release --disable-sandbox --scratch-path /tmp/apple-password-bridge-arc/gated-build +``` + +Expected: complete suite has zero failures and release build exits zero. + +- [ ] **Step 2: Package and strictly verify** + +```bash +make app +gated_package_dir=$(mktemp -d /private/tmp/apple-password-bridge-gated.XXXXXX) +ditto --norsrc --noextattr --noqtn --noacl \ + "dist/Password Bridge.app" "$gated_package_dir/Password Bridge.app" +codesign --verify --deep --strict "$gated_package_dir/Password Bridge.app" +ditto -c -k --norsrc --noextattr --noqtn --noacl --keepParent \ + "$gated_package_dir/Password Bridge.app" \ + "dist/Password-Bridge-Arc-Origin-Gated.zip" +gated_verify_dir=$(mktemp -d /private/tmp/apple-password-bridge-gated-verify.XXXXXX) +ditto -x -k "dist/Password-Bridge-Arc-Origin-Gated.zip" "$gated_verify_dir" +codesign --verify --deep --strict "$gated_verify_dir/Password Bridge.app" +``` + +Expected: both signature checks exit zero. + +- [ ] **Step 3: Manual Arc and regression validation** + +Launch the verified app, open the same Arc authorization popup, and run diagnostics. Expected report: `conclusion=target_recognized`, `axURL=true`, `extension=chrome-extension:///page_popup.html`, and six text fields. Then use manual fill with a fresh code and confirm it fills only the verified Arc popup. + +With Firefox open, confirm title-matched filling still works. With a normal browser page containing similar words and inputs but no extension AXURL, confirm diagnostics do not show a trusted target and automatic fill does not begin. From ecfa4d46eae3d027de577766ef6acc362e34806a Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:23:24 +0800 Subject: [PATCH 22/39] feat: collect Arc authorization origin evidence --- Sources/ApplePasswordBridge/Accessibility.swift | 16 ++++++++++++++++ .../ApplePasswordBridge/BrowserDiagnostics.swift | 12 ++++++++++-- .../BrowserDiagnosticsTests.swift | 7 +++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/Sources/ApplePasswordBridge/Accessibility.swift b/Sources/ApplePasswordBridge/Accessibility.swift index 675d5ea..5fe9601 100644 --- a/Sources/ApplePasswordBridge/Accessibility.swift +++ b/Sources/ApplePasswordBridge/Accessibility.swift @@ -8,6 +8,10 @@ enum AccessibilityValueNormalizer { if let url = value as? NSURL { return url.absoluteString } return nil } + + static func bool(from value: Any?) -> Bool? { + (value as? NSNumber)?.boolValue + } } struct AccessibilityNode { @@ -94,6 +98,18 @@ enum AccessibilityTree { copy(element, attribute: kAXTitleAttribute as CFString) as? String } + static func role(of element: AXUIElement) -> String { + (copy(element, attribute: kAXRoleAttribute as CFString) as? String) ?? "" + } + + static func subrole(of element: AXUIElement) -> String { + (copy(element, attribute: kAXSubroleAttribute as CFString) as? String) ?? "" + } + + static func bool(_ element: AXUIElement, attribute: CFString) -> Bool { + AccessibilityValueNormalizer.bool(from: copy(element, attribute: attribute)) ?? false + } + static func focus(_ element: AXUIElement) -> Bool { AXUIElementSetAttributeValue( element, diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index f7f9de9..465c6d1 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -196,8 +196,16 @@ public struct BrowserDiagnostics: BrowserDiagnosticsRunning, Sendable { let ax = AXUIElementCreateApplication(app.processIdentifier) for w in AccessibilityTree.windows(of: ax) { if Task.isCancelled { break } - let nodes = AccessibilityTree.collect(from: w, maxDepth: 12, maxNodes: 800).map { DiagnosticNode(role: $0.role, text: $0.text) } - axWindows.append(Self.makeAccessibilityObservation(title: AccessibilityTree.title(of: w), nodes: nodes)) + let nodes = AccessibilityTree.collect(from: w, maxDepth: 12, maxNodes: 800).map { DiagnosticNode(role: $0.role, text: $0.text, diagnosticURL: $0.diagnosticURL) } + axWindows.append(Self.makeAccessibilityObservation( + title: AccessibilityTree.title(of: w), + role: AccessibilityTree.role(of: w), + subrole: AccessibilityTree.subrole(of: w), + isModal: AccessibilityTree.bool(w, attribute: kAXModalAttribute as CFString), + isMain: AccessibilityTree.bool(w, attribute: kAXMainAttribute as CFString), + isFocused: AccessibilityTree.bool(w, attribute: kAXFocusedAttribute as CFString), + nodes: nodes + )) } } let activation: String? = app.map { switch $0.activationPolicy { case .regular: return "regular"; case .accessory: return "accessory"; case .prohibited: return "prohibited"; default: return "unknown" } } diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index 2fcc895..cc40f8f 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -44,6 +44,13 @@ final class BrowserDiagnosticsTests: XCTestCase { XCTAssertNil(AccessibilityValueNormalizer.urlString(from: NSNumber(value: 42))) } + func test_accessibility_boolean_normalizer_accepts_cfboolean_and_nsnumber() { + XCTAssertEqual(AccessibilityValueNormalizer.bool(from: kCFBooleanTrue), true) + XCTAssertEqual(AccessibilityValueNormalizer.bool(from: kCFBooleanFalse), false) + XCTAssertEqual(AccessibilityValueNormalizer.bool(from: NSNumber(value: true)), true) + XCTAssertNil(AccessibilityValueNormalizer.bool(from: "true")) + } + @MainActor func test_bridge_model_runs_diagnostics_and_replaces_report() async { let first = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 1), ruleMode: .allowlist, applications: []) From d5f387b695db25d8ee4cb24b024a9dcbf6cdd102 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:43:04 +0800 Subject: [PATCH 23/39] feat: gate untitled popup autofill on extension origin --- Sources/ApplePasswordBridge/BridgeModel.swift | 8 +- .../BrowserDiagnostics.swift | 20 ++- Sources/ApplePasswordBridge/CodeParser.swift | 9 ++ .../ApplePasswordBridge/FirefoxAutofill.swift | 75 +++++++--- .../BrowserDiagnosticsTests.swift | 130 ++++++++++++++++++ .../2026-08-04-arc-origin-gated-autofill.md | 26 ++-- ...-08-04-arc-origin-gated-autofill-design.md | 10 +- 7 files changed, 234 insertions(+), 44 deletions(-) diff --git a/Sources/ApplePasswordBridge/BridgeModel.swift b/Sources/ApplePasswordBridge/BridgeModel.swift index bc709b4..6ae87ec 100644 --- a/Sources/ApplePasswordBridge/BridgeModel.swift +++ b/Sources/ApplePasswordBridge/BridgeModel.swift @@ -303,7 +303,7 @@ final class BridgeModel: ObservableObject { } guard let code = currentCode, code.isFresh() else { currentCode = nil - scheduleRetry(for: [target.identity]) + scheduleRetry(for: [target.retryIdentity]) if manual { statusText = "未发现有效的 Apple 密码验证码窗口" } return } @@ -311,7 +311,7 @@ final class BridgeModel: ObservableObject { $0.value == code.value && $0.isFresh(at: now) } ?? false guard manual || !isRecentlyFilledCode else { - markHandled(target.identity) + markHandled(target.retryIdentity) return } @@ -324,10 +324,10 @@ final class BridgeModel: ObservableObject { source: code.source ) currentCode = nil - markHandled(target.identity) + markHandled(target.retryIdentity) statusText = "已填入 \(target.application.localizedName ?? "目标应用")(\(code.source.rawValue))" } catch { - scheduleRetry(for: [target.identity]) + scheduleRetry(for: [target.retryIdentity]) statusText = error.localizedDescription } } diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index 465c6d1..41004f5 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -80,6 +80,8 @@ public struct DiagnosticAccessibilityWindow: Equatable, Sendable { public let isFocused: Bool public let extensionURLSummary: String? public let hasExtensionURL: Bool + public let hasAccessibilityExtensionURL: Bool + public let hasAccessibilityPopupURL: Bool public let hasPopupPath: Bool public let hasICloudIdentity: Bool public let hasAutofillTerms: Bool @@ -87,11 +89,13 @@ public struct DiagnosticAccessibilityWindow: Equatable, Sendable { public let roleCounts: [String: Int] public let stablePopupSignature: Bool public let authorizationContextMatches: Bool - public init(title: String, extensionURLSummary: String?, hasExtensionURL: Bool, hasPopupPath: Bool, hasICloudIdentity: Bool, hasAutofillTerms: Bool, hasVerificationCodeTerms: Bool, roleCounts: [String: Int], stablePopupSignature: Bool, authorizationContextMatches: Bool, role: String = "", subrole: String = "", isModal: Bool = false, isMain: Bool = false, isFocused: Bool = false) { + public init(title: String, extensionURLSummary: String?, hasExtensionURL: Bool, hasAccessibilityExtensionURL: Bool = false, hasAccessibilityPopupURL: Bool = false, hasPopupPath: Bool, hasICloudIdentity: Bool, hasAutofillTerms: Bool, hasVerificationCodeTerms: Bool, roleCounts: [String: Int], stablePopupSignature: Bool, authorizationContextMatches: Bool, role: String = "", subrole: String = "", isModal: Bool = false, isMain: Bool = false, isFocused: Bool = false) { self.title = DiagnosticRedactor.redactTitle(title) self.role = role; self.subrole = subrole; self.isModal = isModal; self.isMain = isMain; self.isFocused = isFocused self.extensionURLSummary = extensionURLSummary.flatMap { DiagnosticRedactor.extensionURLSummary(from: $0) } self.hasExtensionURL = hasExtensionURL + self.hasAccessibilityExtensionURL = hasAccessibilityExtensionURL + self.hasAccessibilityPopupURL = hasAccessibilityPopupURL self.hasPopupPath = hasPopupPath self.hasICloudIdentity = hasICloudIdentity self.hasAutofillTerms = hasAutofillTerms @@ -134,6 +138,12 @@ public struct BrowserDiagnostics: BrowserDiagnosticsRunning, Sendable { guard running.contains(where: { $0.permitted }) else { return .applicationRejectedByPolicy } let permitted = running.filter { $0.permitted } guard permitted.contains(where: { !$0.windows.isEmpty }) else { return .noVisibleWindows } + let trustedUntitledTargets = permitted.flatMap(\.accessibilityWindows).contains { + $0.hasAccessibilityPopupURL + && $0.authorizationContextMatches + && $0.supportedInputCount >= 6 + } + if trustedUntitledTargets { return .targetRecognized } guard permitted.contains(where: { $0.windows.contains { $0.titleMatches } }) else { return .windowTitleMismatch } guard permitted.contains(where: { !$0.accessibilityWindows.isEmpty }) else { return .accessibilityWindowsUnavailable } let matching = permitted.flatMap { $0.accessibilityWindows }.filter { $0.stablePopupSignature || $0.authorizationContextMatches } @@ -148,12 +158,14 @@ public struct BrowserDiagnostics: BrowserDiagnosticsRunning, Sendable { let lower = all.lowercased() let rawDiagnosticURLs = nodes.compactMap(\.diagnosticURL) let diagnosticURLSummary = rawDiagnosticURLs.compactMap { DiagnosticRedactor.extensionURLSummary(from: $0) }.first + let hasAccessibilityExtensionURL = diagnosticURLSummary != nil + let hasAccessibilityPopupURL = rawDiagnosticURLs.contains(where: AuthorizationContext.isBrowserExtensionPopupURL) let hasURL = diagnosticURLSummary != nil || lower.contains("chrome-extension://") || lower.contains("moz-extension://") let popup = hasURL && (lower.contains("/page_popup.html") || rawDiagnosticURLs.contains { $0.lowercased().contains("/page_popup.html") }) let icloud = AuthorizationContext.isICloudPasswordWindowTitle(title) || lower.contains("icloud 密码") || lower.contains("icloud passwords") var roles: [String: Int] = [:] for n in nodes { roles[n.role, default: 0] += 1 } - return DiagnosticAccessibilityWindow(title: title ?? "", extensionURLSummary: diagnosticURLSummary ?? DiagnosticRedactor.extensionURLSummary(from: combined), hasExtensionURL: hasURL, hasPopupPath: popup, hasICloudIdentity: icloud, hasAutofillTerms: AuthorizationContext.hasAutofillTerms(all), hasVerificationCodeTerms: AuthorizationContext.hasVerificationCodeTerms(all), roleCounts: roles, stablePopupSignature: AuthorizationContext.isBrowserExtensionPopup(title: title, text: combined), authorizationContextMatches: AuthorizationContext.isBrowserExtensionAuthorization(title: title, text: combined), role: role, subrole: subrole, isModal: isModal, isMain: isMain, isFocused: isFocused) + return DiagnosticAccessibilityWindow(title: title ?? "", extensionURLSummary: diagnosticURLSummary ?? DiagnosticRedactor.extensionURLSummary(from: combined), hasExtensionURL: hasURL, hasAccessibilityExtensionURL: hasAccessibilityExtensionURL, hasAccessibilityPopupURL: hasAccessibilityPopupURL, hasPopupPath: popup, hasICloudIdentity: icloud, hasAutofillTerms: AuthorizationContext.hasAutofillTerms(all), hasVerificationCodeTerms: AuthorizationContext.hasVerificationCodeTerms(all), roleCounts: roles, stablePopupSignature: AuthorizationContext.isBrowserExtensionPopup(title: title, text: combined), authorizationContextMatches: AuthorizationContext.isBrowserExtensionAuthorization(title: title, text: combined), role: role, subrole: subrole, isModal: isModal, isMain: isMain, isFocused: isFocused) } static func makeAccessibilityObservation(title: String?, role: String, subrole: String, isModal: Bool, isMain: Bool, isFocused: Bool, nodes: [DiagnosticNode]) -> DiagnosticAccessibilityWindow { makeAccessibilityObservation(title: title, nodes: nodes, role: role, subrole: subrole, isModal: isModal, isMain: isMain, isFocused: isFocused) @@ -233,7 +245,7 @@ struct BrowserDiagnosticReport: Equatable, Sendable { func bit(_ value: Bool) -> String { value ? "1" : "0" } return [ DiagnosticRedactor.redactTitle(ax.title), ax.extensionURLSummary ?? "", ax.role, ax.subrole, - bit(ax.isModal), bit(ax.isMain), bit(ax.isFocused), bit(ax.hasExtensionURL), bit(ax.hasPopupPath), + bit(ax.isModal), bit(ax.isMain), bit(ax.isFocused), bit(ax.hasExtensionURL), bit(ax.hasAccessibilityExtensionURL), bit(ax.hasAccessibilityPopupURL), bit(ax.hasPopupPath), bit(ax.hasICloudIdentity), bit(ax.hasAutofillTerms), bit(ax.hasVerificationCodeTerms), roles, bit(ax.stablePopupSignature), bit(ax.authorizationContextMatches) ] @@ -244,7 +256,7 @@ struct BrowserDiagnosticReport: Equatable, Sendable { return false }) { let roles = ax.roleCounts.keys.sorted().map { "\($0)=\(ax.roleCounts[$0]!)" }.joined(separator: ", ") - lines.append("axTitle=\(DiagnosticRedactor.redactTitle(ax.title)) extension=\(ax.extensionURLSummary ?? "nil") flags=\(ax.hasExtensionURL),\(ax.hasPopupPath),\(ax.hasICloudIdentity),\(ax.hasAutofillTerms),\(ax.hasVerificationCodeTerms) roles=\(roles) windowRole=\(ax.role) windowSubrole=\(ax.subrole) modal=\(ax.isModal) main=\(ax.isMain) focused=\(ax.isFocused) existingMatch=\(ax.stablePopupSignature || ax.authorizationContextMatches)") + lines.append("axTitle=\(DiagnosticRedactor.redactTitle(ax.title)) extension=\(ax.extensionURLSummary ?? "nil") axURL=\(ax.hasAccessibilityExtensionURL) axPopupURL=\(ax.hasAccessibilityPopupURL) flags=\(ax.hasExtensionURL),\(ax.hasPopupPath),\(ax.hasICloudIdentity),\(ax.hasAutofillTerms),\(ax.hasVerificationCodeTerms) roles=\(roles) windowRole=\(ax.role) windowSubrole=\(ax.subrole) modal=\(ax.isModal) main=\(ax.isMain) focused=\(ax.isFocused) existingMatch=\(ax.stablePopupSignature || ax.authorizationContextMatches)") } } return lines.joined(separator: "\n") diff --git a/Sources/ApplePasswordBridge/CodeParser.swift b/Sources/ApplePasswordBridge/CodeParser.swift index 60ec688..ef85252 100644 --- a/Sources/ApplePasswordBridge/CodeParser.swift +++ b/Sources/ApplePasswordBridge/CodeParser.swift @@ -46,6 +46,15 @@ enum AuthorizationContext { return normalized.contains("icloud") && (normalized.contains("密码") || normalized.contains("password")) } + + static func isBrowserExtensionPopupURL(_ url: String) -> Bool { + guard let components = URLComponents(string: url), + let scheme = components.scheme?.lowercased(), + (scheme == "chrome-extension" || scheme == "moz-extension"), + components.host != nil, + components.path == "/page_popup.html" else { return false } + return true + } } enum VerificationCodeParser { diff --git a/Sources/ApplePasswordBridge/FirefoxAutofill.swift b/Sources/ApplePasswordBridge/FirefoxAutofill.swift index 96a60ad..4042551 100644 --- a/Sources/ApplePasswordBridge/FirefoxAutofill.swift +++ b/Sources/ApplePasswordBridge/FirefoxAutofill.swift @@ -24,18 +24,39 @@ enum AutofillFailure: LocalizedError { } final class BrowserAutofill { + static func acceptsTarget( + requiresTrustedOrigin: Bool, + diagnosticURLs: [String], + hasAuthorizationContext: Bool, + hasStablePopupSignature: Bool, + inputCount: Int + ) -> Bool { + if requiresTrustedOrigin { + return diagnosticURLs.contains(where: AuthorizationContext.isBrowserExtensionPopupURL) + && hasAuthorizationContext + && inputCount >= 6 + } + return (hasStablePopupSignature || hasAuthorizationContext) && inputCount > 0 + } + struct WindowIdentity: Hashable { let processIdentifier: pid_t let windowNumber: CGWindowID } + struct CandidateDescriptor: Equatable { + let identity: WindowIdentity + let requiresTrustedOrigin: Bool + } + struct Candidate { let identity: WindowIdentity let application: NSRunningApplication + let requiresTrustedOrigin: Bool } struct Target { - let identity: WindowIdentity + let retryIdentity: WindowIdentity let application: NSRunningApplication let window: AXUIElement let fields: [AccessibilityNode] @@ -50,28 +71,30 @@ final class BrowserAutofill { kCGNullWindowID ) as? [[String: Any]] ?? [] - var candidates: [Candidate] = [] - var seen = Set() - for info in windowInfo { - let title = info[kCGWindowName as String] as? String - guard AuthorizationContext.isICloudPasswordWindowTitle(title), - let rawPID = info[kCGWindowOwnerPID as String] as? NSNumber, - let rawWindowNumber = info[kCGWindowNumber as String] as? NSNumber else { - continue + return Self.selectCandidates(windowInfo: windowInfo, eligiblePIDs: Set(applications.keys)) + .compactMap { descriptor in + guard let application = applications[descriptor.identity.processIdentifier] else { return nil } + return Candidate(identity: descriptor.identity, application: application, requiresTrustedOrigin: descriptor.requiresTrustedOrigin) } - let identity = WindowIdentity( - processIdentifier: rawPID.int32Value, - windowNumber: CGWindowID(rawWindowNumber.uint32Value) - ) - guard !seen.contains(identity), - let application = applications[identity.processIdentifier] else { - continue + } + + static func selectCandidates(windowInfo: [[String: Any]], eligiblePIDs: Set) -> [CandidateDescriptor] { + struct Entry { let descriptor: CandidateDescriptor; let index: Int } + var selected: [pid_t: Entry] = [:] + for (index, info) in windowInfo.enumerated() { + guard let rawPID = info[kCGWindowOwnerPID as String] as? NSNumber, + let rawWindowNumber = info[kCGWindowNumber as String] as? NSNumber else { continue } + let pid = rawPID.int32Value + guard eligiblePIDs.contains(pid) else { continue } + let identity = WindowIdentity(processIdentifier: pid, windowNumber: CGWindowID(rawWindowNumber.uint32Value)) + let trusted = AuthorizationContext.isICloudPasswordWindowTitle(info[kCGWindowName as String] as? String) + if let existing = selected[pid] { + if trusted && existing.descriptor.requiresTrustedOrigin { selected[pid] = Entry(descriptor: CandidateDescriptor(identity: identity, requiresTrustedOrigin: false), index: index) } + } else { + selected[pid] = Entry(descriptor: CandidateDescriptor(identity: identity, requiresTrustedOrigin: !trusted), index: index) } - seen.insert(identity) - candidates.append(Candidate(identity: identity, application: application)) - if candidates.count == 4 { break } } - return candidates + return selected.values.sorted { $0.index < $1.index }.map(\.descriptor) } func prepareAccessibility(for candidates: [Candidate]) { @@ -112,10 +135,18 @@ final class BrowserAutofill { title: title, text: text ) - guard hasStablePopupSignature || hasAuthorizationContext else { continue } + let diagnosticURLs = nodes.compactMap(\.diagnosticURL) + guard Self.acceptsTarget( + requiresTrustedOrigin: applicationCandidates[0].requiresTrustedOrigin, + diagnosticURLs: diagnosticURLs, + hasAuthorizationContext: hasAuthorizationContext, + hasStablePopupSignature: hasStablePopupSignature, + inputCount: fields.count + ) else { continue } guard !fields.isEmpty else { throw AutofillFailure.inputNotFound } + // The retry key comes from CG scheduling; `window` is this verified AX window. return Target( - identity: applicationCandidates[0].identity, + retryIdentity: applicationCandidates[0].identity, application: application, window: window, fields: fields diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index cc40f8f..525de90 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -146,6 +146,72 @@ final class BrowserDiagnosticsTests: XCTestCase { ) } + func test_trusted_accessibility_origin_overrides_mismatched_window_title() { + let mismatched = DiagnosticWindow(number: 1, width: 800, height: 600, title: "Other", titleMatches: false) + let trusted = DiagnosticAccessibilityWindow( + title: "", + extensionURLSummary: "chrome-extension://id/page_popup.html", + hasExtensionURL: true, + hasAccessibilityExtensionURL: true, + hasAccessibilityPopupURL: true, + hasPopupPath: true, + hasICloudIdentity: true, + hasAutofillTerms: true, + hasVerificationCodeTerms: true, + roleCounts: ["AXTextField": 6], + stablePopupSignature: false, + authorizationContextMatches: true + ) + XCTAssertEqual( + BrowserDiagnostics.evaluate([application(windows: [mismatched], accessibilityWindows: [trusted])]), + .targetRecognized + ) + } + + func test_text_discovered_extension_url_does_not_override_mismatched_window_title() { + let mismatched = DiagnosticWindow(number: 1, width: 800, height: 600, title: "Other", titleMatches: false) + let textOnly = DiagnosticAccessibilityWindow( + title: "", + extensionURLSummary: "chrome-extension://id/page_popup.html", + hasExtensionURL: true, + hasAccessibilityExtensionURL: false, + hasAccessibilityPopupURL: false, + hasPopupPath: true, + hasICloudIdentity: true, + hasAutofillTerms: true, + hasVerificationCodeTerms: true, + roleCounts: ["AXTextField": 6], + stablePopupSignature: false, + authorizationContextMatches: true + ) + XCTAssertEqual( + BrowserDiagnostics.evaluate([application(windows: [mismatched], accessibilityWindows: [textOnly])]), + .windowTitleMismatch + ) + } + + func test_accessibility_extension_url_without_popup_path_does_not_override_title_mismatch() { + let mismatched = DiagnosticWindow(number: 1, width: 800, height: 600, title: "Other", titleMatches: false) + let mixedEvidence = DiagnosticAccessibilityWindow( + title: "", + extensionURLSummary: "chrome-extension://id/other.html", + hasExtensionURL: true, + hasAccessibilityExtensionURL: true, + hasAccessibilityPopupURL: false, + hasPopupPath: true, + hasICloudIdentity: true, + hasAutofillTerms: true, + hasVerificationCodeTerms: true, + roleCounts: ["AXTextField": 6], + stablePopupSignature: false, + authorizationContextMatches: true + ) + XCTAssertEqual( + BrowserDiagnostics.evaluate([application(windows: [mismatched], accessibilityWindows: [mixedEvidence])]), + .windowTitleMismatch + ) + } + func test_matching_accessibility_window_is_not_shadowed_by_first_mismatch() { let mismatch = DiagnosticAccessibilityWindow(title: "Other", extensionURLSummary: nil, hasExtensionURL: false, hasPopupPath: false, hasICloudIdentity: false, hasAutofillTerms: false, hasVerificationCodeTerms: false, roleCounts: [:], stablePopupSignature: false, authorizationContextMatches: false) XCTAssertEqual( @@ -190,6 +256,7 @@ final class BrowserDiagnosticsTests: XCTestCase { let report = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 0), ruleMode: .allowlist, applications: [app]) let text = report.plainText XCTAssertTrue(text.contains("")); XCTAssertFalse(text.contains("123456")); XCTAssertFalse(text.contains("abcdefghijklmnop")); XCTAssertFalse(text.contains("popupWindow=42")); XCTAssertTrue(text.contains("AXButton=2, AXTextField=1")) + XCTAssertTrue(text.contains("axURL=true")) XCTAssertTrue(text.contains("windowRole=AXWindow windowSubrole=AXDialog modal=true main=false focused=true")) XCTAssertFalse(text.contains("RAW-PRIVATE-BODY")) } @@ -293,4 +360,67 @@ final class BrowserDiagnosticsTests: XCTestCase { XCTAssertTrue(AuthorizationContext.isApplePasswordAuthorization("自动填充 verification code")) XCTAssertFalse(AuthorizationContext.isApplePasswordAuthorization("自动填充 only")) } + + func test_origin_gated_candidate_selection_prefers_titled_window() { + let windows: [[String: Any]] = [ + [kCGWindowOwnerPID as String: NSNumber(value: 42), kCGWindowNumber as String: NSNumber(value: 9), kCGWindowName as String: ""], + [kCGWindowOwnerPID as String: NSNumber(value: 42), kCGWindowNumber as String: NSNumber(value: 10), kCGWindowName as String: "iCloud Passwords"] + ] + let result = BrowserAutofill.selectCandidates(windowInfo: windows, eligiblePIDs: [42]) + XCTAssertEqual(result, [BrowserAutofill.CandidateDescriptor(identity: .init(processIdentifier: 42, windowNumber: 10), requiresTrustedOrigin: false)]) + } + + func test_origin_gated_candidate_selection_marks_untitled_fallback() { + let windows: [[String: Any]] = [[kCGWindowOwnerPID as String: NSNumber(value: 42), kCGWindowNumber as String: NSNumber(value: 9), kCGWindowName as String: ""]] + let result = BrowserAutofill.selectCandidates(windowInfo: windows, eligiblePIDs: [42]) + XCTAssertEqual(result, [BrowserAutofill.CandidateDescriptor(identity: .init(processIdentifier: 42, windowNumber: 9), requiresTrustedOrigin: true)]) + } + + func test_browser_extension_popup_url_helper() { + XCTAssertTrue(AuthorizationContext.isBrowserExtensionPopupURL("chrome-extension://id/page_popup.html")) + XCTAssertTrue(AuthorizationContext.isBrowserExtensionPopupURL("moz-extension://id/page_popup.html?x=1")) + XCTAssertFalse(AuthorizationContext.isBrowserExtensionPopupURL("https://id/page_popup.html")) + XCTAssertFalse(AuthorizationContext.isBrowserExtensionPopupURL("chrome-extension://id/other.html")) + } + + func test_origin_gated_target_requires_ax_url_context_and_six_fields() { + XCTAssertTrue(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: true, + diagnosticURLs: ["chrome-extension://id/page_popup.html"], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 6 + )) + XCTAssertFalse(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: true, + diagnosticURLs: [], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 6 + )) + XCTAssertFalse(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: true, + diagnosticURLs: ["https://example.com/page_popup.html"], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 6 + )) + XCTAssertFalse(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: true, + diagnosticURLs: ["chrome-extension://id/page_popup.html"], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 5 + )) + } + + func test_title_candidate_retains_legacy_target_acceptance() { + XCTAssertTrue(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: false, + diagnosticURLs: [], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 1 + )) + } } diff --git a/docs/superpowers/plans/2026-08-04-arc-origin-gated-autofill.md b/docs/superpowers/plans/2026-08-04-arc-origin-gated-autofill.md index bf5fc0f..5ac7aa4 100644 --- a/docs/superpowers/plans/2026-08-04-arc-origin-gated-autofill.md +++ b/docs/superpowers/plans/2026-08-04-arc-origin-gated-autofill.md @@ -4,7 +4,7 @@ **Goal:** Allow Arc's untitled iCloud Passwords extension popup to use the existing fill path only after a trusted AXURL origin, authorization context, and six input fields are verified. -**Architecture:** Candidate discovery emits one candidate per permitted browser PID: a title-matched window when present, otherwise the PID's topmost visible window marked origin-gated. Title candidates preserve legacy target matching; origin-gated candidates may succeed only with an AX-attribute `chrome-extension`/`moz-extension` popup URL, existing context, and six inputs. Diagnostics explicitly record whether an extension URL came from AX attributes. +**Architecture:** Candidate discovery and fallback acceptance land atomically: one candidate per permitted browser PID is title-matched when present or topmost-visible and origin-gated otherwise. An origin-gated candidate may succeed only with a parsed AX-attribute `chrome-extension`/`moz-extension` URL whose path is exactly `/page_popup.html`, existing context, and six inputs. Diagnostics explicitly record whether an extension URL came from AX attributes. **Tech Stack:** Swift 5.10, AppKit/ApplicationServices/CoreGraphics, XCTest, SwiftPM. @@ -46,6 +46,8 @@ git commit -m "feat: collect Arc authorization origin evidence" - Modify: `Sources/ApplePasswordBridge/CodeParser.swift:26-48` - Test: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` +**Safety boundary:** This task may add pure helpers only. Do not wire fallback descriptors into `authorizationWindowCandidates` until Task 2 adds the matching trusted-origin gate in the same commit. + - [ ] **Step 1: Write failing pure selection and URL-origin tests** Add tests around a new internal helper that selects a single `WindowIdentity` plus `requiresTrustedOrigin` per eligible PID from CG dictionaries: @@ -126,10 +128,13 @@ Add the pure authorization helper: ```swift static func isBrowserExtensionPopupURL(_ url: String) -> Bool { - let lower = url.lowercased() - let isExtension = lower.hasPrefix("chrome-extension://") - || lower.hasPrefix("moz-extension://") - return isExtension && lower.contains("/page_popup.html") + guard let components = URLComponents(string: url), + let scheme = components.scheme?.lowercased(), + ["chrome-extension", "moz-extension"].contains(scheme), + components.host != nil else { + return false + } + return components.path == "/page_popup.html" } ``` @@ -141,10 +146,10 @@ Run the focused tests and the complete suite. Expected: all tests pass. git add Sources/ApplePasswordBridge/FirefoxAutofill.swift \ Sources/ApplePasswordBridge/CodeParser.swift \ Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift -git commit -m "feat: select origin-gated browser candidates" +git commit -m "feat: add origin-gated candidate helpers" ``` -### Task 2: Gate fallback fill targets on AXURL origin, context, and six inputs +### Task 2: Atomically wire and gate fallback fill targets on AXURL origin, context, and six inputs **Files:** - Modify: `Sources/ApplePasswordBridge/FirefoxAutofill.swift:32-125` @@ -209,7 +214,7 @@ Extend `Candidate`: let requiresTrustedOrigin: Bool ``` -Rewrite `authorizationWindowCandidates(policy:)` to build `eligibleApplicationsByPID`, call `selectCandidates(windowInfo:eligiblePIDs:)`, and map descriptors to applications. Preserve exactly one candidate per eligible PID. Do not activate or focus applications in this method. +Rewrite `authorizationWindowCandidates(policy:)` to build `eligibleApplicationsByPID`, call `selectCandidates(windowInfo:eligiblePIDs:)`, and map descriptors to applications. Preserve exactly one candidate per eligible PID. This wiring and Step 4's gate must be committed together. Do not activate or focus applications in this method. - [ ] **Step 4: Implement the pure target gate and use it in locateTarget** @@ -266,7 +271,7 @@ Run the focused evaluator test. Expected: the current evaluator returns `.window - [ ] **Step 3: Add source-provenance boolean** -Add `hasAccessibilityExtensionURL` to `DiagnosticAccessibilityWindow`, defaulting to `false` at the end of its initializer. In `makeAccessibilityObservation`, set it only when a raw `DiagnosticNode.diagnosticURL` itself passes `DiagnosticRedactor.extensionURLSummary`; text-discovered URLs must leave it false. Render it as `axURL=true` or `axURL=false` in the deterministic AX report line and include it in the AX sort key. +Add `hasAccessibilityExtensionURL` and `hasAccessibilityPopupURL` to `DiagnosticAccessibilityWindow`, defaulting to `false` at the end of its initializer. In `makeAccessibilityObservation`, set the former only when a raw `DiagnosticNode.diagnosticURL` itself passes `DiagnosticRedactor.extensionURLSummary`, and set the latter only when that raw AX URL satisfies `isBrowserExtensionPopupURL`; text-discovered URLs and paths must leave both false. Render both provenance flags in the deterministic AX report line and include them in the AX sort key. - [ ] **Step 4: Prioritize trusted AX targets in evaluate** @@ -274,8 +279,7 @@ Before the CG title mismatch guard, compute trusted AX windows: ```swift let trusted = permitted.flatMap(\.accessibilityWindows).filter { - $0.hasAccessibilityExtensionURL - && $0.hasPopupPath + $0.hasAccessibilityPopupURL && $0.authorizationContextMatches } if trusted.contains(where: { $0.supportedInputCount >= 6 }) { diff --git a/docs/superpowers/specs/2026-08-04-arc-origin-gated-autofill-design.md b/docs/superpowers/specs/2026-08-04-arc-origin-gated-autofill-design.md index ff0dc2d..610e1df 100644 --- a/docs/superpowers/specs/2026-08-04-arc-origin-gated-autofill-design.md +++ b/docs/superpowers/specs/2026-08-04-arc-origin-gated-autofill-design.md @@ -19,20 +19,24 @@ The current production gate rejects this window before AX inspection because its Keep the existing Core Graphics title match as the preferred fast path. For each permitted running browser process that has no titled candidate, select one fallback candidate from its topmost on-screen Core Graphics window. This is only a scheduling identity for retry and handled-window bookkeeping; it is not a trust signal. -The fallback candidate receives the same read-only AX traversal as a titled candidate, but it may return a fill target only if a single AX window satisfies every condition below: +Candidate selection and fallback target gating are one atomic production change. The fallback candidate receives the same read-only AX traversal as a titled candidate, but it may return a fill target only if a single AX window satisfies every condition below: -1. an AX attribute URL, not text scraping, has either the `chrome-extension` or `moz-extension` scheme and a `/page_popup.html` path; +1. an AX attribute URL, not text scraping, parses with either the `chrome-extension` or `moz-extension` scheme, has an extension host, and has the exact `/page_popup.html` path; 2. existing authorization context recognizes an iCloud/extension identity plus autofill and verification-code terms; 3. the AX window exposes at least six supported text inputs. The selected AX window is still the one raised and filled by the existing code. The Core Graphics fallback identity is never used to infer extension origin or select fields. +The fallback identity is an ordering and retry key only. It never binds an AX target to a Core Graphics window: the AX window that passes all three checks is the only window that can be raised or filled. + ## Candidate and Retry Behavior Candidate discovery reads Core Graphics windows once per scan. For each eligible PID, it selects the first title-matching window in window-list order; if none matches, it selects that PID's first visible window in the same order and marks it as origin-gated. At most one candidate is emitted per PID. Existing `WindowIdentity`, retry backoff, and handled-window storage remain unchanged. Because the fallback identity follows the topmost window, a newly shown popup obtains a new identity and is eligible for an immediate AX check; an unchanged browser window remains governed by the existing retry backoff. No new timer, AX observer, window-size threshold, or private API is introduced. +The pre-existing retry path returns before code capture and Vision OCR whenever no AX target passes the gate. Therefore non-matching fallback candidates do not invoke OCR. + ## Security Boundary The legacy title-matched path retains its current behavior. The new titleless path is stricter: it requires an AXURL-derived extension popup origin, full existing authorization context, and six supported inputs. A window title, empty title, dimensions, visible strings, bundle ID, or role alone cannot authorize automatic filling. @@ -41,7 +45,7 @@ The implementation must keep raw extension URLs transient. It may use them to te ## Diagnostic Semantics -After the production predicate exists, the diagnostic evaluator should report `target_recognized` for a permitted application's AX window with trusted extension URL, context, and supported inputs even if every CG title mismatches. `window_title_mismatch` remains the result only when no trusted AX target is present. +After the production predicate exists, the diagnostic evaluator should report `target_recognized` for a permitted application's AX window with an AX-attribute URL that independently satisfies the same extension scheme, host, and exact popup-path predicate, plus context and supported inputs, even if every CG title mismatches. Text-derived URL and path hints remain diagnostic-only and cannot form a trusted target. `window_title_mismatch` remains the result only when no trusted AX target is present. ## Tests and Manual Validation From dc8bed417b2fe8f60c02caef2d14cf718757f827 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:03:24 +0800 Subject: [PATCH 24/39] docs: design adaptive scan interval --- ...026-09-02-adaptive-scan-interval-design.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-02-adaptive-scan-interval-design.md diff --git a/docs/superpowers/specs/2026-09-02-adaptive-scan-interval-design.md b/docs/superpowers/specs/2026-09-02-adaptive-scan-interval-design.md new file mode 100644 index 0000000..25ad4f6 --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-adaptive-scan-interval-design.md @@ -0,0 +1,26 @@ +# Adaptive Scan Interval Design + +## Goal + +Lower background energy use while preserving fast, safe Arc authorization-popup autofill. + +## Current Behavior + +The bridge scans every 0.5 seconds, including when no authorization popup is visible. Each scan collects browser Accessibility trees, which is disproportionately expensive for Arc. + +## Chosen Design + +Introduce a pure scan-scheduling policy with two modes: + +- **Idle:** run every 3 seconds when no new candidate window is present. +- **Active candidate:** run every 0.25 seconds for up to 2 seconds after a new Core Graphics candidate window appears. This gives Arc time to populate its AX URL, authorization context, and six fields. + +After the active window expires without a verified target, scheduling returns to idle. Existing retry backoff remains in force after target lookup failures. Manual fill bypasses the timer and starts a scan immediately. + +## Safety Boundary + +This changes scheduling only. A titleless popup remains eligible only when its AX attribute URL exactly identifies an extension `page_popup.html`, the authorization context matches, and at least six supported inputs exist. No window-size, bundle-ID, or page-text exception is added. + +## Testing + +Unit tests will cover mode selection, active-window expiration, and manual-scan immediacy. Existing origin-gate tests remain unchanged. From c8826f8c8ca3dfaa2577201b82ee7edc5eecb42d Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:44:24 +0800 Subject: [PATCH 25/39] feat: add adaptive scan schedule policy --- Sources/ApplePasswordBridge/BridgeModel.swift | 10 ++++++++++ .../BrowserDiagnosticsTests.swift | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/Sources/ApplePasswordBridge/BridgeModel.swift b/Sources/ApplePasswordBridge/BridgeModel.swift index 6ae87ec..5dd39aa 100644 --- a/Sources/ApplePasswordBridge/BridgeModel.swift +++ b/Sources/ApplePasswordBridge/BridgeModel.swift @@ -3,6 +3,16 @@ import Combine import ServiceManagement import UniformTypeIdentifiers +struct ScanSchedule { + static let idleInterval: TimeInterval = 3 + static let activeInterval: TimeInterval = 0.25 + + static func interval(now: Date, activeUntil: Date?) -> TimeInterval { + guard let activeUntil, activeUntil > now else { return idleInterval } + return activeInterval + } +} + private final class DiagnosticsTaskBox: @unchecked Sendable { private let lock = NSLock() private var task: Task? diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index 525de90..b0bbaa9 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -32,6 +32,17 @@ private final class BlockingDiagnosticsRunner: BrowserDiagnosticsRunning, @unche } final class BrowserDiagnosticsTests: XCTestCase { + func test_scan_schedule_uses_idle_interval_without_active_deadline() { + let now = Date(timeIntervalSince1970: 100) + XCTAssertEqual(ScanSchedule.interval(now: now, activeUntil: nil), 3) + } + + func test_scan_schedule_uses_fast_interval_until_deadline() { + let now = Date(timeIntervalSince1970: 100) + XCTAssertEqual(ScanSchedule.interval(now: now, activeUntil: now.addingTimeInterval(0.1)), 0.25) + XCTAssertEqual(ScanSchedule.interval(now: now, activeUntil: now), 3) + } + func test_accessibility_url_normalizer_accepts_string_url_nsurl_and_cfurl() { let raw = "chrome-extension://secret-id/page_popup.html?popupWindow=42#token" let foundationURL = URL(string: raw)! From 319c2a0f2ac845025066cbc4f6729e38953777a0 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:11:03 +0800 Subject: [PATCH 26/39] perf: reduce idle browser scan frequency --- Sources/ApplePasswordBridge/BridgeModel.swift | 37 +++- .../BrowserDiagnosticsTests.swift | 16 ++ .../2026-09-07-adaptive-scan-interval.md | 178 ++++++++++++++++++ 3 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-07-adaptive-scan-interval.md diff --git a/Sources/ApplePasswordBridge/BridgeModel.swift b/Sources/ApplePasswordBridge/BridgeModel.swift index 5dd39aa..1013e59 100644 --- a/Sources/ApplePasswordBridge/BridgeModel.swift +++ b/Sources/ApplePasswordBridge/BridgeModel.swift @@ -11,6 +11,10 @@ struct ScanSchedule { guard let activeUntil, activeUntil > now else { return idleInterval } return activeInterval } + + static func activeUntil(now: Date, hasNewCandidate: Bool) -> Date? { + hasNewCandidate ? now.addingTimeInterval(2) : nil + } } private final class DiagnosticsTaskBox: @unchecked Sendable { @@ -85,6 +89,8 @@ final class BridgeModel: ObservableObject { private var scanInProgress = false private var manualRequestPending = false private var started = false + private var knownCandidateIdentities: Set = [] + private var activeScanUntil: Date? init(browserDiagnostics: any BrowserDiagnosticsRunning = BrowserDiagnostics(), startAutomatically: Bool = true) { self.browserDiagnostics = browserDiagnostics @@ -166,19 +172,35 @@ final class BridgeModel: ObservableObject { let hotKeyReady = hotKey?.start() == true statusText = hotKeyReady ? "等待 Apple 密码授权窗口" : "快捷键注册失败" - scanTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + scheduleNextScan() + } + + func fillNow() async { + await scanAndFill(manual: true) + } + + private func scheduleNextScan() { + scanTimer?.invalidate() + let now = Date() + let interval = ScanSchedule.interval(now: now, activeUntil: activeScanUntil) + let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in Task { @MainActor [weak self] in guard let self else { return } if self.monitoringEnabled && self.automaticFillEnabled { await self.scanAndFill(manual: false) } + self.scheduleNextScan() } } - scanTimer?.tolerance = 0.15 + timer.tolerance = interval == ScanSchedule.activeInterval ? 0.05 : 0.4 + scanTimer = timer } - func fillNow() async { - await scanAndFill(manual: true) + static func hasNewCandidate( + _ candidates: Set, + previous: Set + ) -> Bool { + !candidates.isSubset(of: previous) } func requestAccessibility() { @@ -264,6 +286,13 @@ final class BridgeModel: ObservableObject { let allCandidates = browserAutofill.authorizationWindowCandidates(policy: applicationPolicy) let visibleIdentities = Set(allCandidates.map(\.identity)) let now = Date() + if let activeUntil = ScanSchedule.activeUntil( + now: now, + hasNewCandidate: Self.hasNewCandidate(visibleIdentities, previous: knownCandidateIdentities) + ) { + activeScanUntil = activeUntil + } + knownCandidateIdentities = visibleIdentities handledWindows = handledWindows.filter { visibleIdentities.contains($0.key) && now.timeIntervalSince($0.value) < 10 } diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index b0bbaa9..bf035ae 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -43,6 +43,22 @@ final class BrowserDiagnosticsTests: XCTestCase { XCTAssertEqual(ScanSchedule.interval(now: now, activeUntil: now), 3) } + @MainActor + func test_new_candidate_window_activates_fast_scan_window() { + let candidate = BrowserAutofill.WindowIdentity(processIdentifier: 42, windowNumber: 7) + XCTAssertTrue(BridgeModel.hasNewCandidate([candidate], previous: [])) + XCTAssertFalse(BridgeModel.hasNewCandidate([candidate], previous: [candidate])) + } + + func test_scan_schedule_active_until_is_two_seconds_for_new_candidate() { + let now = Date(timeIntervalSince1970: 100) + XCTAssertEqual( + ScanSchedule.activeUntil(now: now, hasNewCandidate: true), + now.addingTimeInterval(2) + ) + XCTAssertNil(ScanSchedule.activeUntil(now: now, hasNewCandidate: false)) + } + func test_accessibility_url_normalizer_accepts_string_url_nsurl_and_cfurl() { let raw = "chrome-extension://secret-id/page_popup.html?popupWindow=42#token" let foundationURL = URL(string: raw)! diff --git a/docs/superpowers/plans/2026-09-07-adaptive-scan-interval.md b/docs/superpowers/plans/2026-09-07-adaptive-scan-interval.md new file mode 100644 index 0000000..6b2972c --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-adaptive-scan-interval.md @@ -0,0 +1,178 @@ +# Adaptive Scan Interval Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reduce idle Accessibility polling while preserving immediate manual fills and fast Arc popup recognition. + +**Architecture:** Add a pure `ScanSchedule` policy that returns a 3-second idle interval or a 0.25-second active interval based on a bounded candidate-observation deadline. `BridgeModel` owns the deadline, updates it when a new Core Graphics candidate identity appears, and reschedules its existing timer without changing target trust or retry behavior. + +**Tech Stack:** Swift 5.10, AppKit, XCTest, SwiftPM. + +--- + +### Task 1: Add and test the pure scan schedule + +**Files:** +- Modify: `Sources/ApplePasswordBridge/BridgeModel.swift:28-77` +- Modify: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Write the failing tests** + +```swift +func test_scan_schedule_uses_idle_interval_without_active_deadline() { + XCTAssertEqual( + ScanSchedule.interval(now: Date(timeIntervalSince1970: 10), activeUntil: nil), + 3 + ) +} + +func test_scan_schedule_uses_fast_interval_until_deadline() { + let now = Date(timeIntervalSince1970: 10) + XCTAssertEqual(ScanSchedule.interval(now: now, activeUntil: now.addingTimeInterval(2)), 0.25) + XCTAssertEqual(ScanSchedule.interval(now: now, activeUntil: now), 3) +} +``` + +- [ ] **Step 2: Run the focused tests to verify RED** + +Run: + +```bash +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-adaptive/cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-adaptive/cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-adaptive/cache/swiftpm \ +swift test --disable-sandbox --scratch-path /tmp/apple-password-bridge-adaptive/build \ + --filter BrowserDiagnosticsTests/test_scan_schedule_uses_idle_interval_without_active_deadline +``` + +Expected: compilation failure because `ScanSchedule` does not exist. + +- [ ] **Step 3: Add the minimal pure policy** + +```swift +enum ScanSchedule { + static let idleInterval: TimeInterval = 3 + static let activeInterval: TimeInterval = 0.25 + + static func interval(now: Date, activeUntil: Date?) -> TimeInterval { + guard let activeUntil, activeUntil > now else { return idleInterval } + return activeInterval + } +} +``` + +- [ ] **Step 4: Run the focused tests to verify GREEN** + +Run the same command twice, substituting the second test name. Expected: both pass. + +- [ ] **Step 5: Commit the pure policy** + +```bash +git add Sources/ApplePasswordBridge/BridgeModel.swift Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: add adaptive scan schedule policy" +``` + +### Task 2: Reschedule scanning around new candidates + +**Files:** +- Modify: `Sources/ApplePasswordBridge/BridgeModel.swift:68-77,138-168,244-292` +- Test: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Write the failing candidate-window helper test** + +```swift +func test_new_candidate_window_activates_fast_scan_window() { + let current: Set = [ + .init(processIdentifier: 42, windowNumber: 7) + ] + XCTAssertTrue(BridgeModel.hasNewCandidate(current, previous: [])) + XCTAssertFalse(BridgeModel.hasNewCandidate(current, previous: current)) +} +``` + +- [ ] **Step 2: Run the focused test to verify RED** + +Run the SwiftPM command above with `--filter BrowserDiagnosticsTests/test_new_candidate_window_activates_fast_scan_window`. + +Expected: compilation failure because `hasNewCandidate` does not exist. + +- [ ] **Step 3: Implement scheduling state and timer replacement** + +Add `knownCandidateIdentities` and `activeScanUntil` to `BridgeModel`. Add: + +```swift +static func hasNewCandidate( + _ current: Set, + previous: Set +) -> Bool { + !current.subtracting(previous).isEmpty +} +``` + +Replace the fixed timer creation with `scheduleNextScan()`: + +```swift +private func scheduleNextScan() { + scanTimer?.invalidate() + let interval = ScanSchedule.interval(now: Date(), activeUntil: activeScanUntil) + scanTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + if self.monitoringEnabled && self.automaticFillEnabled { + await self.scanAndFill(manual: false) + } + self.scheduleNextScan() + } + } + scanTimer?.tolerance = interval == ScanSchedule.idleInterval ? 0.4 : 0.05 +} +``` + +Inside `scanAndFill`, after `allCandidates` is computed, detect newly observed identities. When one appears, set `activeScanUntil = now.addingTimeInterval(2)`. Then update `knownCandidateIdentities = visibleIdentities`. Do not change `authorizationWindowCandidates`, `locateTarget`, retry backoff, AX URL verification, or the manual code path. + +- [ ] **Step 4: Run all tests to verify GREEN** + +Run: + +```bash +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-adaptive/cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-adaptive/cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-adaptive/cache/swiftpm \ +swift test --disable-sandbox --scratch-path /tmp/apple-password-bridge-adaptive/build +``` + +Expected: complete suite passes with no failures. + +- [ ] **Step 5: Commit the scheduling integration** + +```bash +git add Sources/ApplePasswordBridge/BridgeModel.swift Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "perf: reduce idle browser scan frequency" +``` + +### Task 3: Verify release behavior + +**Files:** +- No source changes expected. + +- [ ] **Step 1: Run a fresh full test suite** + +```bash +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-adaptive/final-cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-adaptive/final-cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-adaptive/final-cache/swiftpm \ +swift test --disable-sandbox --scratch-path /tmp/apple-password-bridge-adaptive/final-build +``` + +Expected: complete suite passes with no failures. + +- [ ] **Step 2: Build release configuration** + +```bash +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-adaptive/final-cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-adaptive/final-cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-adaptive/final-cache/swiftpm \ +swift build -c release --disable-sandbox --scratch-path /tmp/apple-password-bridge-adaptive/final-release-build +``` + +Expected: release build exits 0. From 02e3da3d7369fe8cf0e6eec1d3d62daa656df465 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:11:27 +0800 Subject: [PATCH 27/39] docs: design screen recording permission feedback --- ...en-recording-permission-feedback-design.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-08-screen-recording-permission-feedback-design.md diff --git a/docs/superpowers/specs/2026-09-08-screen-recording-permission-feedback-design.md b/docs/superpowers/specs/2026-09-08-screen-recording-permission-feedback-design.md new file mode 100644 index 0000000..ed41f42 --- /dev/null +++ b/docs/superpowers/specs/2026-09-08-screen-recording-permission-feedback-design.md @@ -0,0 +1,21 @@ +# Screen Recording Permission Feedback Design + +## Goal + +Make the screen-recording permission action useful when macOS suppresses its authorization prompt after a prior refusal. + +## Options Considered + +1. Keep the current request-only button. This cannot recover when TCC no longer displays a prompt. +2. Always open System Settings. This is reliable but skips the normal first-time prompt. +3. Request first, then open System Settings only when permission remains unavailable. This preserves the first-time path and gives a deterministic recovery path. Chosen. + +## Behavior + +When the user taps “授权” for screen recording, the app requests access through `CGRequestScreenCaptureAccess()`, refreshes the permission state, and opens Screen Recording settings if access is still unavailable. The status text explains that the user must enable the app in System Settings. + +The accessibility permission behavior is unchanged. No permission is granted programmatically; the app only guides the user to macOS settings. + +## Testing + +Extract the post-request decision into a pure helper. Tests cover both outcomes: granted keeps settings closed; denied opens settings guidance. Existing permission API calls remain isolated in `PermissionManager`. From 7826dac07c38c8a5e0756c98ed3eb62022b922ff Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:31:47 +0800 Subject: [PATCH 28/39] fix: guide denied screen recording permission to settings --- Sources/ApplePasswordBridge/BridgeModel.swift | 11 +- .../PermissionManager.swift | 9 +- .../BrowserDiagnosticsTests.swift | 5 + ...08-screen-recording-permission-feedback.md | 102 ++++++++++++++++++ 4 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-08-screen-recording-permission-feedback.md diff --git a/Sources/ApplePasswordBridge/BridgeModel.swift b/Sources/ApplePasswordBridge/BridgeModel.swift index 1013e59..5ca1594 100644 --- a/Sources/ApplePasswordBridge/BridgeModel.swift +++ b/Sources/ApplePasswordBridge/BridgeModel.swift @@ -209,11 +209,14 @@ final class BridgeModel: ObservableObject { } func requestScreenRecording() { - PermissionManager.requestScreenRecording() + _ = PermissionManager.requestScreenRecording() refreshPermissions() - statusText = screenRecordingGranted - ? "录屏权限已允许" - : "请在系统设置中允许屏幕与系统录音权限" + if PermissionManager.needsScreenRecordingSettings(granted: screenRecordingGranted) { + openScreenRecordingSettings() + statusText = "请在系统设置中允许屏幕与系统音频录制权限" + } else { + statusText = "录屏权限已允许" + } } func refreshPermissionStatus() { diff --git a/Sources/ApplePasswordBridge/PermissionManager.swift b/Sources/ApplePasswordBridge/PermissionManager.swift index 751cf61..fa748e0 100644 --- a/Sources/ApplePasswordBridge/PermissionManager.swift +++ b/Sources/ApplePasswordBridge/PermissionManager.swift @@ -18,8 +18,13 @@ enum PermissionManager { _ = AXIsProcessTrustedWithOptions(options) } - static func requestScreenRecording() { - _ = CGRequestScreenCaptureAccess() + @discardableResult + static func requestScreenRecording() -> Bool { + CGRequestScreenCaptureAccess() + } + + static func needsScreenRecordingSettings(granted: Bool) -> Bool { + !granted } static func openAccessibilitySettings() { diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index bf035ae..5a85b53 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -32,6 +32,11 @@ private final class BlockingDiagnosticsRunner: BrowserDiagnosticsRunning, @unche } final class BrowserDiagnosticsTests: XCTestCase { + func test_screen_recording_settings_are_needed_when_request_is_not_granted() { + XCTAssertTrue(PermissionManager.needsScreenRecordingSettings(granted: false)) + XCTAssertFalse(PermissionManager.needsScreenRecordingSettings(granted: true)) + } + func test_scan_schedule_uses_idle_interval_without_active_deadline() { let now = Date(timeIntervalSince1970: 100) XCTAssertEqual(ScanSchedule.interval(now: now, activeUntil: nil), 3) diff --git a/docs/superpowers/plans/2026-09-08-screen-recording-permission-feedback.md b/docs/superpowers/plans/2026-09-08-screen-recording-permission-feedback.md new file mode 100644 index 0000000..ddf8486 --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-screen-recording-permission-feedback.md @@ -0,0 +1,102 @@ +# Screen Recording Permission Feedback Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Guide users to Screen Recording settings when macOS does not grant access after the in-app request. + +**Architecture:** Keep the macOS request in `PermissionManager`, but return its Boolean result. `BridgeModel` refreshes its published permission state after each request and, if still denied, opens the existing Screen Recording settings URL and gives the user a clear status message. A pure helper makes the post-request decision unit-testable. + +**Tech Stack:** Swift 5.10, AppKit, CoreGraphics, XCTest, SwiftPM. + +--- + +### Task 1: Make failed screen-recording requests lead to settings + +**Files:** +- Modify: `Sources/ApplePasswordBridge/PermissionManager.swift:21-31` +- Modify: `Sources/ApplePasswordBridge/BridgeModel.swift:207-214` +- Modify: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Write the failing decision test** + +```swift +func test_screen_recording_settings_are_needed_when_request_is_not_granted() { + XCTAssertTrue(PermissionManager.needsScreenRecordingSettings(granted: false)) + XCTAssertFalse(PermissionManager.needsScreenRecordingSettings(granted: true)) +} +``` + +- [ ] **Step 2: Run the focused test to verify RED** + +```bash +env DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ +CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-permission/cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-permission/cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-permission/cache/swiftpm \ +swift test --disable-sandbox --scratch-path /tmp/apple-password-bridge-permission/build \ + --filter BrowserDiagnosticsTests/test_screen_recording_settings_are_needed_when_request_is_not_granted +``` + +Expected: compilation failure because `needsScreenRecordingSettings` does not exist. + +- [ ] **Step 3: Implement the minimal request result and fallback** + +```swift +// PermissionManager.swift +static func requestScreenRecording() -> Bool { + CGRequestScreenCaptureAccess() +} + +static func needsScreenRecordingSettings(granted: Bool) -> Bool { + !granted +} +``` + +```swift +// BridgeModel.swift +func requestScreenRecording() { + _ = PermissionManager.requestScreenRecording() + refreshPermissions() + if PermissionManager.needsScreenRecordingSettings(granted: screenRecordingGranted) { + PermissionManager.openScreenRecordingSettings() + statusText = "请在系统设置中允许屏幕与系统音频录制权限" + } else { + statusText = "录屏权限已允许" + } +} +``` + +The ignored request result is intentional: macOS permission state after `refreshPermissions()` is the source of truth. Do not modify the Accessibility permission request or SwiftUI button layout. + +- [ ] **Step 4: Run focused and complete tests to verify GREEN** + +```bash +env DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ +CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-permission/cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-permission/cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-permission/cache/swiftpm \ +swift test --disable-sandbox --scratch-path /tmp/apple-password-bridge-permission/build +``` + +Expected: focused test and complete suite pass with no failures. + +- [ ] **Step 5: Build release configuration** + +```bash +env DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ +CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-permission/release-cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-permission/release-cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-permission/release-cache/swiftpm \ +swift build -c release --disable-sandbox --scratch-path /tmp/apple-password-bridge-permission/release-build +``` + +Expected: release build exits 0. + +- [ ] **Step 6: Commit** + +```bash +git add Sources/ApplePasswordBridge/PermissionManager.swift \ + Sources/ApplePasswordBridge/BridgeModel.swift \ + Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "fix: guide denied screen recording permission to settings" +``` From d90de980e90cfbd591baf11d3dec58316b1398af Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:17:20 +0800 Subject: [PATCH 29/39] docs: design candidate-aware accessibility retries --- ...didate-aware-accessibility-retry-design.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-09-candidate-aware-accessibility-retry-design.md diff --git a/docs/superpowers/specs/2026-09-09-candidate-aware-accessibility-retry-design.md b/docs/superpowers/specs/2026-09-09-candidate-aware-accessibility-retry-design.md new file mode 100644 index 0000000..1bab276 --- /dev/null +++ b/docs/superpowers/specs/2026-09-09-candidate-aware-accessibility-retry-design.md @@ -0,0 +1,61 @@ +# Candidate-Aware Accessibility Retry Design + +## Problem + +The adaptive scan timer reduced the idle polling interval, but an ordinary visible Arc window still becomes an authorization candidate. Each eligible automatic scan then runs five accessibility warm-up attempts. Every attempt walks the browser's accessibility windows and may collect up to 800 nodes, so a stable non-popup browser window can repeatedly consume substantial CPU and energy. + +## Goal + +Reduce idle accessibility work without weakening the existing autofill trust boundary or making newly opened authorization popups slower to recognize. + +## Non-goals + +- Do not change application allowlist or denylist behavior. +- Do not trust a window because of its dimensions. +- Do not relax extension URL, authorization-context, or input-count validation. +- Do not redesign candidate discovery around accessibility observers in this change. + +## Design + +Introduce a small, deterministic retry policy that selects accessibility warm-up delays from scan context: + +- Manual scans use the existing five delays: 120, 160, 220, 300, and 400 milliseconds. +- Automatic scans containing a newly observed candidate identity use the same five delays. +- Automatic scans containing only stable candidate identities perform one accessibility lookup, after the existing shortest 120 millisecond warm-up. + +`BridgeModel` already compares the current candidate identity set with the previous set. The result will be captured before `knownCandidateIdentities` is updated and passed, together with the `manual` flag, to the retry policy. The selected delays are then used by `locateTargetAfterAccessibilityWarmup`. + +Candidate discovery and target acceptance remain unchanged. A titleless Arc popup must still expose an approved extension origin, the expected popup path and authorization context, and at least six text inputs. Window dimensions are not part of the trust decision or retry decision. + +## Data Flow + +1. Collect visible candidates and their process/window identities. +2. Determine whether this scan contains an identity absent from the previous scan. +3. Update the active scan deadline and remembered identities as today. +4. Select retry delays from `manual` and `hasNewCandidate`. +5. Run accessibility target location using those delays. +6. Preserve the existing success handling and exponential failure backoff. + +This reduces a stable failed automatic scan from five full accessibility traversals to one. A newly created popup retains the current warm-up budget during the two-second active window's first scan, while subsequent stable scans avoid repeating the full sequence. + +## Failure Handling + +The last accessibility error is returned exactly as it is today. Automatic failures continue through the existing per-window exponential backoff. Manual requests continue to surface the localized error and retain the full retry sequence. + +## Testing + +Add unit tests for the pure retry policy before implementation: + +- manual scan selects five warm-up attempts; +- automatic scan with a new candidate selects five warm-up attempts; +- automatic scan with only stable candidates selects one warm-up attempt; +- an expired active deadline still returns to the three-second idle interval. + +Run the complete Swift test suite and a Release build. Existing tests for trusted extension origins, authorization context, popup path, and six-input minimum must remain green. Build and code-sign a clean distributable app archive after verification. + +## Success Criteria + +- Stable automatic scans perform one accessibility tree traversal per eligible retry instead of five. +- New candidate and manual scans retain the current five-attempt behavior. +- No target acceptance or security rule changes. +- Full tests and Release build pass. From fde03c3760e5b1a790b0fc38fb6efd91481fe6cc Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:36:40 +0800 Subject: [PATCH 30/39] docs: plan candidate-aware accessibility retries --- ...-09-candidate-aware-accessibility-retry.md | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-09-candidate-aware-accessibility-retry.md diff --git a/docs/superpowers/plans/2026-09-09-candidate-aware-accessibility-retry.md b/docs/superpowers/plans/2026-09-09-candidate-aware-accessibility-retry.md new file mode 100644 index 0000000..3e4c8ee --- /dev/null +++ b/docs/superpowers/plans/2026-09-09-candidate-aware-accessibility-retry.md @@ -0,0 +1,263 @@ +# Candidate-Aware Accessibility Retry Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (\`- [ ]\`) syntax for tracking. + +**Goal:** Reduce stable automatic browser scans from five accessibility traversals to one while retaining five attempts for new candidates and manual requests. + +**Architecture:** Add a pure retry-delay policy beside the existing scan scheduling policy. \`BridgeModel.scanAndFill\` computes candidate novelty once, uses it for both the active interval and retry intensity, and passes the selected delays into the existing accessibility warm-up loop. Candidate discovery and target trust validation remain untouched. + +**Tech Stack:** Swift 5.9+, AppKit Accessibility APIs, XCTest, Swift Package Manager + +--- + +## File Structure + +- Modify \`Sources/ApplePasswordBridge/BridgeModel.swift\`: define the retry-delay policy and route scan context into the accessibility warm-up loop. +- Modify \`Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift\`: specify manual, new-candidate automatic, stable automatic, and expired-active-window behavior. + +### Task 1: Specify the Retry Policy + +**Files:** +- Modify: \`Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift:40-65\` +- Modify: \`Sources/ApplePasswordBridge/BridgeModel.swift:6-18\` + +- [ ] **Step 1: Write failing policy tests** + +Add these tests near the existing \`ScanSchedule\` tests: + +\`\`\`swift +func test_accessibility_retry_schedule_keeps_full_warmup_for_manual_scan() { + XCTAssertEqual( + AccessibilityRetrySchedule.delays(manual: true, hasNewCandidate: false), + [120, 160, 220, 300, 400] + ) +} + +func test_accessibility_retry_schedule_keeps_full_warmup_for_new_automatic_candidate() { + XCTAssertEqual( + AccessibilityRetrySchedule.delays(manual: false, hasNewCandidate: true), + [120, 160, 220, 300, 400] + ) +} + +func test_accessibility_retry_schedule_uses_one_attempt_for_stable_automatic_candidate() { + XCTAssertEqual( + AccessibilityRetrySchedule.delays(manual: false, hasNewCandidate: false), + [120] + ) +} + +func test_scan_schedule_returns_to_idle_after_active_deadline() { + let now = Date(timeIntervalSince1970: 100) + XCTAssertEqual( + ScanSchedule.interval(now: now, activeUntil: now.addingTimeInterval(-0.001)), + ScanSchedule.idleInterval + ) +} +\`\`\` + +- [ ] **Step 2: Run focused tests and verify RED** + +Run: + +\`\`\`bash +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ + CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-clang-cache \ + SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-swift-cache \ + swift test --filter BrowserDiagnosticsTests +\`\`\` + +Expected: compilation fails because \`AccessibilityRetrySchedule\` does not exist. This confirms the new tests exercise missing production behavior. + +- [ ] **Step 3: Add the minimal pure policy** + +Add immediately after \`ScanSchedule\`: + +\`\`\`swift +struct AccessibilityRetrySchedule { + static let fullWarmupDelays: [UInt64] = [120, 160, 220, 300, 400] + static let stableAutomaticDelays: [UInt64] = [120] + + static func delays(manual: Bool, hasNewCandidate: Bool) -> [UInt64] { + manual || hasNewCandidate ? fullWarmupDelays : stableAutomaticDelays + } +} +\`\`\` + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run the focused command from Step 2. + +Expected: all \`BrowserDiagnosticsTests\` pass with zero failures. + +- [ ] **Step 5: Commit the tested policy** + +\`\`\`bash +git add Sources/ApplePasswordBridge/BridgeModel.swift \ + Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "perf: define candidate-aware accessibility retries" +\`\`\` + +### Task 2: Route Scan Context into AX Warm-up + +**Files:** +- Modify: \`Sources/ApplePasswordBridge/BridgeModel.swift:279-393\` + +- [ ] **Step 1: Reuse the tested novelty result** + +In \`scanAndFill(manual:)\`, compute novelty before updating \`knownCandidateIdentities\`: + +\`\`\`swift +let hasNewCandidate = Self.hasNewCandidate( + visibleIdentities, + previous: knownCandidateIdentities +) +if let activeUntil = ScanSchedule.activeUntil( + now: now, + hasNewCandidate: hasNewCandidate +) { + activeScanUntil = activeUntil +} +knownCandidateIdentities = visibleIdentities +\`\`\` + +- [ ] **Step 2: Pass retry delays into the locator** + +Replace the target lookup with: + +\`\`\`swift +let retryDelays = AccessibilityRetrySchedule.delays( + manual: manual, + hasNewCandidate: hasNewCandidate +) +target = try await locateTargetAfterAccessibilityWarmup( + candidates: candidates, + retryDelays: retryDelays +) +\`\`\` + +Change the helper signature and remove its local fixed delay declaration: + +\`\`\`swift +private func locateTargetAfterAccessibilityWarmup( + candidates: [BrowserAutofill.Candidate], + retryDelays: [UInt64] +) async throws -> BrowserAutofill.Target { + browserAutofill.prepareAccessibility(for: candidates) + + var lastError: Error = AutofillFailure.authorizationWindowNotFound + for delayMilliseconds in retryDelays { + try await Task.sleep(nanoseconds: delayMilliseconds * 1_000_000) + do { + return try browserAutofill.locateTarget(candidates: candidates) + } catch { + lastError = error + } + } + throw lastError +} +\`\`\` + +- [ ] **Step 3: Run focused policy and diagnostics tests** + +Run: + +\`\`\`bash +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ + CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-clang-cache \ + SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-swift-cache \ + swift test --filter BrowserDiagnosticsTests +\`\`\` + +Expected: all focused tests pass with zero failures. + +- [ ] **Step 4: Inspect the focused diff for security isolation** + +Run: + +\`\`\`bash +git diff -- Sources/ApplePasswordBridge/BridgeModel.swift \ + Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +\`\`\` + +Expected: changes only affect retry scheduling and tests. \`FirefoxAutofill.swift\`, \`AuthorizationContext\`, URL validation, authorization-context validation, and input-count validation are unchanged. + +- [ ] **Step 5: Commit integration** + +\`\`\`bash +git add Sources/ApplePasswordBridge/BridgeModel.swift +git commit -m "perf: avoid repeated AX walks for stable windows" +\`\`\` + +### Task 3: Full Verification and Distributable Build + +**Files:** +- Verify: all source and test files +- Create: \`DiagnosticBuild/Password-Bridge-Candidate-Aware-Scan.zip\` + +- [ ] **Step 1: Run the complete test suite** + +Run: + +\`\`\`bash +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ + CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-clang-cache \ + SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-swift-cache \ + swift test +\`\`\` + +Expected: all tests pass with zero failures. + +- [ ] **Step 2: Build Release** + +Run: + +\`\`\`bash +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ + CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-clang-cache \ + SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-swift-cache \ + swift build -c release +\`\`\` + +Expected: exit status 0 and \`Build complete!\`. + +- [ ] **Step 3: Build the app bundle with the repository script** + +Run: + +\`\`\`bash +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer ./scripts/build-app.sh +\`\`\` + +Expected: \`dist/Password Bridge.app\` is produced and signed. + +- [ ] **Step 4: Copy without Finder metadata and verify strict signing** + +Create a fresh temporary directory with \`mktemp -d\`, then run: + +\`\`\`bash +ditto --norsrc --noextattr --noqtn --noacl \ + "dist/Password Bridge.app" \ + "$TEMP_BUILD_DIR/Password Bridge.app" +codesign --verify --deep --strict --verbose=2 \ + "$TEMP_BUILD_DIR/Password Bridge.app" +\`\`\` + +Expected: signing verification exits 0. + +- [ ] **Step 5: Create and independently verify the ZIP** + +Archive the clean copy using \`ditto -c -k --keepParent\`, extract it to a second fresh temporary directory, and run \`codesign --verify --deep --strict --verbose=2\` on the extracted app. + +Expected: extracted app verification exits 0. Move the verified archive to \`DiagnosticBuild/Password-Bridge-Candidate-Aware-Scan.zip\`. + +- [ ] **Step 6: Review repository state** + +Run: + +\`\`\`bash +git status --short +git log -3 --oneline +\`\`\` + +Expected: only the intentionally untracked distributable ZIP may remain; source and tests are committed. From 6444a2ae4fd054362c149b9783d031f9b1e7be8c Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:45:54 +0800 Subject: [PATCH 31/39] docs: plan candidate-aware accessibility retries --- ...-09-candidate-aware-accessibility-retry.md | 272 ++---------------- 1 file changed, 29 insertions(+), 243 deletions(-) diff --git a/docs/superpowers/plans/2026-09-09-candidate-aware-accessibility-retry.md b/docs/superpowers/plans/2026-09-09-candidate-aware-accessibility-retry.md index 3e4c8ee..a8bc54e 100644 --- a/docs/superpowers/plans/2026-09-09-candidate-aware-accessibility-retry.md +++ b/docs/superpowers/plans/2026-09-09-candidate-aware-accessibility-retry.md @@ -1,263 +1,49 @@ # Candidate-Aware Accessibility Retry Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (\`- [ ]\`) syntax for tracking. +**Goal:** Reduce stable Arc idle scanning from five accessibility tree traversals to one while preserving full warm-up for new candidates and manual fills. -**Goal:** Reduce stable automatic browser scans from five accessibility traversals to one while retaining five attempts for new candidates and manual requests. +**Architecture:** Extend the existing pure `ScanSchedule` policy with context-aware accessibility retry delays. `BridgeModel` computes candidate novelty once per scan and passes the selected delay sequence to the existing target locator. Candidate discovery and AX target validation remain unchanged. -**Architecture:** Add a pure retry-delay policy beside the existing scan scheduling policy. \`BridgeModel.scanAndFill\` computes candidate novelty once, uses it for both the active interval and retry intensity, and passes the selected delays into the existing accessibility warm-up loop. Candidate discovery and target trust validation remain untouched. - -**Tech Stack:** Swift 5.9+, AppKit Accessibility APIs, XCTest, Swift Package Manager +**Tech Stack:** Swift 5.10, AppKit, XCTest, SwiftPM. --- -## File Structure - -- Modify \`Sources/ApplePasswordBridge/BridgeModel.swift\`: define the retry-delay policy and route scan context into the accessibility warm-up loop. -- Modify \`Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift\`: specify manual, new-candidate automatic, stable automatic, and expired-active-window behavior. - -### Task 1: Specify the Retry Policy +### Task 1: Add the pure retry policy with TDD **Files:** -- Modify: \`Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift:40-65\` -- Modify: \`Sources/ApplePasswordBridge/BridgeModel.swift:6-18\` - -- [ ] **Step 1: Write failing policy tests** - -Add these tests near the existing \`ScanSchedule\` tests: - -\`\`\`swift -func test_accessibility_retry_schedule_keeps_full_warmup_for_manual_scan() { - XCTAssertEqual( - AccessibilityRetrySchedule.delays(manual: true, hasNewCandidate: false), - [120, 160, 220, 300, 400] - ) -} - -func test_accessibility_retry_schedule_keeps_full_warmup_for_new_automatic_candidate() { - XCTAssertEqual( - AccessibilityRetrySchedule.delays(manual: false, hasNewCandidate: true), - [120, 160, 220, 300, 400] - ) -} - -func test_accessibility_retry_schedule_uses_one_attempt_for_stable_automatic_candidate() { - XCTAssertEqual( - AccessibilityRetrySchedule.delays(manual: false, hasNewCandidate: false), - [120] - ) -} - -func test_scan_schedule_returns_to_idle_after_active_deadline() { - let now = Date(timeIntervalSince1970: 100) - XCTAssertEqual( - ScanSchedule.interval(now: now, activeUntil: now.addingTimeInterval(-0.001)), - ScanSchedule.idleInterval - ) -} -\`\`\` - -- [ ] **Step 2: Run focused tests and verify RED** - -Run: - -\`\`\`bash -DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ - CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-clang-cache \ - SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-swift-cache \ - swift test --filter BrowserDiagnosticsTests -\`\`\` - -Expected: compilation fails because \`AccessibilityRetrySchedule\` does not exist. This confirms the new tests exercise missing production behavior. - -- [ ] **Step 3: Add the minimal pure policy** - -Add immediately after \`ScanSchedule\`: +- Modify: `Sources/ApplePasswordBridge/BridgeModel.swift` +- Modify: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` -\`\`\`swift -struct AccessibilityRetrySchedule { - static let fullWarmupDelays: [UInt64] = [120, 160, 220, 300, 400] - static let stableAutomaticDelays: [UInt64] = [120] +- [ ] Add failing tests asserting five delays for manual scans and new automatic candidates, and one delay for stable automatic candidates. +- [ ] Run only the new tests and confirm they fail because the policy does not exist. +- [ ] Add the minimal `ScanSchedule.accessibilityRetryDelays(manual:hasNewCandidate:)` implementation. +- [ ] Re-run the focused tests and confirm they pass. - static func delays(manual: Bool, hasNewCandidate: Bool) -> [UInt64] { - manual || hasNewCandidate ? fullWarmupDelays : stableAutomaticDelays - } -} -\`\`\` - -- [ ] **Step 4: Run focused tests and verify GREEN** - -Run the focused command from Step 2. - -Expected: all \`BrowserDiagnosticsTests\` pass with zero failures. - -- [ ] **Step 5: Commit the tested policy** - -\`\`\`bash -git add Sources/ApplePasswordBridge/BridgeModel.swift \ - Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift -git commit -m "perf: define candidate-aware accessibility retries" -\`\`\` - -### Task 2: Route Scan Context into AX Warm-up +### Task 2: Integrate candidate novelty into target lookup **Files:** -- Modify: \`Sources/ApplePasswordBridge/BridgeModel.swift:279-393\` - -- [ ] **Step 1: Reuse the tested novelty result** - -In \`scanAndFill(manual:)\`, compute novelty before updating \`knownCandidateIdentities\`: - -\`\`\`swift -let hasNewCandidate = Self.hasNewCandidate( - visibleIdentities, - previous: knownCandidateIdentities -) -if let activeUntil = ScanSchedule.activeUntil( - now: now, - hasNewCandidate: hasNewCandidate -) { - activeScanUntil = activeUntil -} -knownCandidateIdentities = visibleIdentities -\`\`\` - -- [ ] **Step 2: Pass retry delays into the locator** - -Replace the target lookup with: - -\`\`\`swift -let retryDelays = AccessibilityRetrySchedule.delays( - manual: manual, - hasNewCandidate: hasNewCandidate -) -target = try await locateTargetAfterAccessibilityWarmup( - candidates: candidates, - retryDelays: retryDelays -) -\`\`\` - -Change the helper signature and remove its local fixed delay declaration: - -\`\`\`swift -private func locateTargetAfterAccessibilityWarmup( - candidates: [BrowserAutofill.Candidate], - retryDelays: [UInt64] -) async throws -> BrowserAutofill.Target { - browserAutofill.prepareAccessibility(for: candidates) - - var lastError: Error = AutofillFailure.authorizationWindowNotFound - for delayMilliseconds in retryDelays { - try await Task.sleep(nanoseconds: delayMilliseconds * 1_000_000) - do { - return try browserAutofill.locateTarget(candidates: candidates) - } catch { - lastError = error - } - } - throw lastError -} -\`\`\` +- Modify: `Sources/ApplePasswordBridge/BridgeModel.swift` -- [ ] **Step 3: Run focused policy and diagnostics tests** +- [ ] Capture `hasNewCandidate` before updating `knownCandidateIdentities`. +- [ ] Reuse it for the active deadline and retry policy. +- [ ] Pass the selected delays into `locateTargetAfterAccessibilityWarmup`. +- [ ] Keep `BrowserAutofill.selectCandidates`, `acceptsTarget`, extension-origin verification, and input validation untouched. +- [ ] Run the complete test suite. -Run: - -\`\`\`bash -DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ - CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-clang-cache \ - SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-swift-cache \ - swift test --filter BrowserDiagnosticsTests -\`\`\` - -Expected: all focused tests pass with zero failures. - -- [ ] **Step 4: Inspect the focused diff for security isolation** - -Run: - -\`\`\`bash -git diff -- Sources/ApplePasswordBridge/BridgeModel.swift \ - Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift -\`\`\` - -Expected: changes only affect retry scheduling and tests. \`FirefoxAutofill.swift\`, \`AuthorizationContext\`, URL validation, authorization-context validation, and input-count validation are unchanged. - -- [ ] **Step 5: Commit integration** - -\`\`\`bash -git add Sources/ApplePasswordBridge/BridgeModel.swift -git commit -m "perf: avoid repeated AX walks for stable windows" -\`\`\` - -### Task 3: Full Verification and Distributable Build +### Task 3: Verify and package **Files:** -- Verify: all source and test files -- Create: \`DiagnosticBuild/Password-Bridge-Candidate-Aware-Scan.zip\` - -- [ ] **Step 1: Run the complete test suite** - -Run: - -\`\`\`bash -DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ - CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-clang-cache \ - SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-swift-cache \ - swift test -\`\`\` - -Expected: all tests pass with zero failures. - -- [ ] **Step 2: Build Release** - -Run: - -\`\`\`bash -DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ - CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-clang-cache \ - SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-swift-cache \ - swift build -c release -\`\`\` - -Expected: exit status 0 and \`Build complete!\`. - -- [ ] **Step 3: Build the app bundle with the repository script** - -Run: - -\`\`\`bash -DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer ./scripts/build-app.sh -\`\`\` - -Expected: \`dist/Password Bridge.app\` is produced and signed. - -- [ ] **Step 4: Copy without Finder metadata and verify strict signing** - -Create a fresh temporary directory with \`mktemp -d\`, then run: - -\`\`\`bash -ditto --norsrc --noextattr --noqtn --noacl \ - "dist/Password Bridge.app" \ - "$TEMP_BUILD_DIR/Password Bridge.app" -codesign --verify --deep --strict --verbose=2 \ - "$TEMP_BUILD_DIR/Password Bridge.app" -\`\`\` - -Expected: signing verification exits 0. - -- [ ] **Step 5: Create and independently verify the ZIP** - -Archive the clean copy using \`ditto -c -k --keepParent\`, extract it to a second fresh temporary directory, and run \`codesign --verify --deep --strict --verbose=2\` on the extracted app. - -Expected: extracted app verification exits 0. Move the verified archive to \`DiagnosticBuild/Password-Bridge-Candidate-Aware-Scan.zip\`. - -- [ ] **Step 6: Review repository state** +- No source changes expected. -Run: +- [ ] Run a fresh complete test suite using `/Applications/Xcode.app` and isolated `/tmp` caches. +- [ ] Run a fresh Release build. +- [ ] Review the diff to confirm only retry scheduling and tests changed. +- [ ] Commit the implementation. +- [ ] Copy the Release app without resource forks, verify strict code signing, create a ZIP, extract it, and verify strict code signing again. -\`\`\`bash -git status --short -git log -3 --oneline -\`\`\` +## Success Criteria -Expected: only the intentionally untracked distributable ZIP may remain; source and tests are committed. +- Stable automatic scans select exactly one AX lookup attempt. +- New automatic candidates and manual scans select all five existing attempts. +- Existing security acceptance tests remain unchanged and pass. +- Complete tests, Release build, and both signing checks pass. From 131647c94f5475cb535f62da7588fff24f3fc4ea Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:51:34 +0800 Subject: [PATCH 32/39] perf: avoid repeated AX walks for stable windows --- Sources/ApplePasswordBridge/BridgeModel.swift | 26 ++++++++++++++--- .../BrowserDiagnosticsTests.swift | 29 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/Sources/ApplePasswordBridge/BridgeModel.swift b/Sources/ApplePasswordBridge/BridgeModel.swift index 5ca1594..6407f8b 100644 --- a/Sources/ApplePasswordBridge/BridgeModel.swift +++ b/Sources/ApplePasswordBridge/BridgeModel.swift @@ -6,6 +6,8 @@ import UniformTypeIdentifiers struct ScanSchedule { static let idleInterval: TimeInterval = 3 static let activeInterval: TimeInterval = 0.25 + static let fullAccessibilityRetryDelays: [UInt64] = [120, 160, 220, 300, 400] + static let stableAccessibilityRetryDelays: [UInt64] = [120] static func interval(now: Date, activeUntil: Date?) -> TimeInterval { guard let activeUntil, activeUntil > now else { return idleInterval } @@ -15,6 +17,12 @@ struct ScanSchedule { static func activeUntil(now: Date, hasNewCandidate: Bool) -> Date? { hasNewCandidate ? now.addingTimeInterval(2) : nil } + + static func accessibilityRetryDelays(manual: Bool, hasNewCandidate: Bool) -> [UInt64] { + manual || hasNewCandidate + ? fullAccessibilityRetryDelays + : stableAccessibilityRetryDelays + } } private final class DiagnosticsTaskBox: @unchecked Sendable { @@ -289,9 +297,13 @@ final class BridgeModel: ObservableObject { let allCandidates = browserAutofill.authorizationWindowCandidates(policy: applicationPolicy) let visibleIdentities = Set(allCandidates.map(\.identity)) let now = Date() + let hasNewCandidate = Self.hasNewCandidate( + visibleIdentities, + previous: knownCandidateIdentities + ) if let activeUntil = ScanSchedule.activeUntil( now: now, - hasNewCandidate: Self.hasNewCandidate(visibleIdentities, previous: knownCandidateIdentities) + hasNewCandidate: hasNewCandidate ) { activeScanUntil = activeUntil } @@ -326,7 +338,13 @@ final class BridgeModel: ObservableObject { let target: BrowserAutofill.Target do { - target = try await locateTargetAfterAccessibilityWarmup(candidates: candidates) + target = try await locateTargetAfterAccessibilityWarmup( + candidates: candidates, + retryDelays: ScanSchedule.accessibilityRetryDelays( + manual: manual, + hasNewCandidate: hasNewCandidate + ) + ) } catch { scheduleRetry(for: candidates.map(\.identity)) if manual { statusText = error.localizedDescription } @@ -375,11 +393,11 @@ final class BridgeModel: ObservableObject { } private func locateTargetAfterAccessibilityWarmup( - candidates: [BrowserAutofill.Candidate] + candidates: [BrowserAutofill.Candidate], + retryDelays: [UInt64] ) async throws -> BrowserAutofill.Target { browserAutofill.prepareAccessibility(for: candidates) - let retryDelays: [UInt64] = [120, 160, 220, 300, 400] var lastError: Error = AutofillFailure.authorizationWindowNotFound for delayMilliseconds in retryDelays { try await Task.sleep(nanoseconds: delayMilliseconds * 1_000_000) diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index 5a85b53..8737b10 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -64,6 +64,35 @@ final class BrowserDiagnosticsTests: XCTestCase { XCTAssertNil(ScanSchedule.activeUntil(now: now, hasNewCandidate: false)) } + func test_accessibility_retry_schedule_keeps_full_warmup_for_manual_scan() { + XCTAssertEqual( + ScanSchedule.accessibilityRetryDelays(manual: true, hasNewCandidate: false), + [120, 160, 220, 300, 400] + ) + } + + func test_accessibility_retry_schedule_keeps_full_warmup_for_new_automatic_candidate() { + XCTAssertEqual( + ScanSchedule.accessibilityRetryDelays(manual: false, hasNewCandidate: true), + [120, 160, 220, 300, 400] + ) + } + + func test_accessibility_retry_schedule_uses_one_attempt_for_stable_automatic_candidate() { + XCTAssertEqual( + ScanSchedule.accessibilityRetryDelays(manual: false, hasNewCandidate: false), + [120] + ) + } + + func test_scan_schedule_returns_to_idle_after_active_deadline() { + let now = Date(timeIntervalSince1970: 100) + XCTAssertEqual( + ScanSchedule.interval(now: now, activeUntil: now.addingTimeInterval(-0.001)), + ScanSchedule.idleInterval + ) + } + func test_accessibility_url_normalizer_accepts_string_url_nsurl_and_cfurl() { let raw = "chrome-extension://secret-id/page_popup.html?popupWindow=42#token" let foundationURL = URL(string: raw)! From 25162f637acd9dd2a7ef59e683bcd3261c1c2760 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:22:58 +0800 Subject: [PATCH 33/39] feat: make idle scan interval configurable --- .../ApplicationRules.swift | 14 ++++++ Sources/ApplePasswordBridge/BridgeApp.swift | 13 +++++ Sources/ApplePasswordBridge/BridgeModel.swift | 24 ++++++++-- .../BrowserDiagnosticsTests.swift | 48 +++++++++++++++++-- 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/Sources/ApplePasswordBridge/ApplicationRules.swift b/Sources/ApplePasswordBridge/ApplicationRules.swift index c00dda6..b7976a5 100644 --- a/Sources/ApplePasswordBridge/ApplicationRules.swift +++ b/Sources/ApplePasswordBridge/ApplicationRules.swift @@ -24,6 +24,20 @@ enum FillSpeed: String, CaseIterable, Identifiable { } } +enum IdleScanInterval: Double, CaseIterable, Identifiable { + case seconds3 = 3 + case seconds5 = 5 + case seconds10 = 10 + case seconds15 = 15 + case seconds30 = 30 + + static let defaultValue: IdleScanInterval = .seconds15 + + var id: Double { rawValue } + var seconds: TimeInterval { rawValue } + var title: String { "\(Int(rawValue)) 秒" } +} + enum ApplicationRuleMode: String, CaseIterable, Identifiable, Sendable { case allowlist case denylist diff --git a/Sources/ApplePasswordBridge/BridgeApp.swift b/Sources/ApplePasswordBridge/BridgeApp.swift index 52790c3..ea0cb55 100644 --- a/Sources/ApplePasswordBridge/BridgeApp.swift +++ b/Sources/ApplePasswordBridge/BridgeApp.swift @@ -56,6 +56,19 @@ private struct BridgeMenu: View { .pickerStyle(.segmented) } + VStack(alignment: .leading, spacing: 8) { + Text("空闲扫描间隔") + .font(.caption) + .foregroundStyle(.secondary) + Picker("空闲扫描间隔", selection: $model.idleScanInterval) { + ForEach(IdleScanInterval.allCases) { interval in + Text(interval.title).tag(interval) + } + } + .labelsHidden() + .pickerStyle(.segmented) + } + Button { Task { await model.fillNow() } } label: { diff --git a/Sources/ApplePasswordBridge/BridgeModel.swift b/Sources/ApplePasswordBridge/BridgeModel.swift index 6407f8b..8062883 100644 --- a/Sources/ApplePasswordBridge/BridgeModel.swift +++ b/Sources/ApplePasswordBridge/BridgeModel.swift @@ -4,12 +4,15 @@ import ServiceManagement import UniformTypeIdentifiers struct ScanSchedule { - static let idleInterval: TimeInterval = 3 static let activeInterval: TimeInterval = 0.25 static let fullAccessibilityRetryDelays: [UInt64] = [120, 160, 220, 300, 400] static let stableAccessibilityRetryDelays: [UInt64] = [120] - static func interval(now: Date, activeUntil: Date?) -> TimeInterval { + static func interval( + now: Date, + activeUntil: Date?, + idleInterval: TimeInterval + ) -> TimeInterval { guard let activeUntil, activeUntil > now else { return idleInterval } return activeInterval } @@ -61,6 +64,12 @@ final class BridgeModel: ObservableObject { @Published var fillSpeed: FillSpeed { didSet { UserDefaults.standard.set(fillSpeed.rawValue, forKey: Keys.fillSpeed) } } + @Published var idleScanInterval: IdleScanInterval { + didSet { + UserDefaults.standard.set(idleScanInterval.rawValue, forKey: Keys.idleScanInterval) + if started { scheduleNextScan() } + } + } @Published var applicationRuleMode: ApplicationRuleMode { didSet { UserDefaults.standard.set(applicationRuleMode.rawValue, forKey: Keys.applicationRuleMode) } } @@ -79,6 +88,7 @@ final class BridgeModel: ObservableObject { static let automaticFill = "automaticFillEnabled" static let ocrFallback = "ocrFallbackEnabled" static let fillSpeed = "fillSpeed" + static let idleScanInterval = "idleScanInterval" static let applicationRuleMode = "applicationRuleMode" static let allowlistedApplications = "allowlistedApplications" static let denylistedApplications = "denylistedApplications" @@ -107,6 +117,7 @@ final class BridgeModel: ObservableObject { Keys.automaticFill: true, Keys.ocrFallback: true, Keys.fillSpeed: FillSpeed.reliable.rawValue, + Keys.idleScanInterval: IdleScanInterval.defaultValue.rawValue, Keys.applicationRuleMode: ApplicationRuleMode.allowlist.rawValue ]) monitoringEnabled = UserDefaults.standard.bool(forKey: Keys.monitoring) @@ -115,6 +126,9 @@ final class BridgeModel: ObservableObject { fillSpeed = FillSpeed( rawValue: UserDefaults.standard.string(forKey: Keys.fillSpeed) ?? "" ) ?? .reliable + idleScanInterval = IdleScanInterval( + rawValue: UserDefaults.standard.double(forKey: Keys.idleScanInterval) + ) ?? .defaultValue applicationRuleMode = ApplicationRuleMode( rawValue: UserDefaults.standard.string(forKey: Keys.applicationRuleMode) ?? "" ) ?? .allowlist @@ -190,7 +204,11 @@ final class BridgeModel: ObservableObject { private func scheduleNextScan() { scanTimer?.invalidate() let now = Date() - let interval = ScanSchedule.interval(now: now, activeUntil: activeScanUntil) + let interval = ScanSchedule.interval( + now: now, + activeUntil: activeScanUntil, + idleInterval: idleScanInterval.seconds + ) let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in Task { @MainActor [weak self] in guard let self else { return } diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index 8737b10..f3c8ba3 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -39,13 +39,47 @@ final class BrowserDiagnosticsTests: XCTestCase { func test_scan_schedule_uses_idle_interval_without_active_deadline() { let now = Date(timeIntervalSince1970: 100) - XCTAssertEqual(ScanSchedule.interval(now: now, activeUntil: nil), 3) + XCTAssertEqual(ScanSchedule.interval(now: now, activeUntil: nil, idleInterval: 15), 15) } func test_scan_schedule_uses_fast_interval_until_deadline() { let now = Date(timeIntervalSince1970: 100) - XCTAssertEqual(ScanSchedule.interval(now: now, activeUntil: now.addingTimeInterval(0.1)), 0.25) - XCTAssertEqual(ScanSchedule.interval(now: now, activeUntil: now), 3) + XCTAssertEqual( + ScanSchedule.interval(now: now, activeUntil: now.addingTimeInterval(0.1), idleInterval: 30), + 0.25 + ) + XCTAssertEqual(ScanSchedule.interval(now: now, activeUntil: now, idleInterval: 30), 30) + } + + func test_idle_scan_intervals_expose_exact_seconds() { + XCTAssertEqual(IdleScanInterval.allCases.map(\.seconds), [3, 5, 10, 15, 30]) + XCTAssertEqual(IdleScanInterval.allCases.map(\.title), ["3 秒", "5 秒", "10 秒", "15 秒", "30 秒"]) + } + + func test_idle_scan_interval_defaults_to_fifteen_seconds_and_round_trips_raw_value() { + XCTAssertEqual(IdleScanInterval.defaultValue, .seconds15) + XCTAssertEqual(IdleScanInterval(rawValue: 15), .seconds15) + } + + @MainActor + func test_bridge_model_reads_and_persists_idle_scan_interval() { + let key = "idleScanInterval" + let defaults = UserDefaults.standard + let originalValue = defaults.object(forKey: key) + defer { + if let originalValue { + defaults.set(originalValue, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + defaults.set(30.0, forKey: key) + let model = BridgeModel(startAutomatically: false) + XCTAssertEqual(model.idleScanInterval, .seconds30) + + model.idleScanInterval = .seconds5 + XCTAssertEqual(defaults.double(forKey: key), 5) } @MainActor @@ -88,8 +122,12 @@ final class BrowserDiagnosticsTests: XCTestCase { func test_scan_schedule_returns_to_idle_after_active_deadline() { let now = Date(timeIntervalSince1970: 100) XCTAssertEqual( - ScanSchedule.interval(now: now, activeUntil: now.addingTimeInterval(-0.001)), - ScanSchedule.idleInterval + ScanSchedule.interval( + now: now, + activeUntil: now.addingTimeInterval(-0.001), + idleInterval: 15 + ), + 15 ) } From c58c9a7ae70823fd74e97f3cd705d3d7d64aa3cf Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:29:19 +0800 Subject: [PATCH 34/39] docs: design event-driven autofill monitoring --- ...2026-09-10-event-driven-autofill-design.md | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-10-event-driven-autofill-design.md diff --git a/docs/superpowers/specs/2026-09-10-event-driven-autofill-design.md b/docs/superpowers/specs/2026-09-10-event-driven-autofill-design.md new file mode 100644 index 0000000..c43b41a --- /dev/null +++ b/docs/superpowers/specs/2026-09-10-event-driven-autofill-design.md @@ -0,0 +1,103 @@ +# Event-Driven Autofill Design + +## Problem + +Periodic polling forces a poor trade-off: short intervals recognize browser authorization popups quickly but repeatedly walk large accessibility trees, while long intervals save energy but delay autofill. The current candidate-aware retry policy reduces each stable scan to one AX traversal, but a traversal still occurs on every configured idle interval. + +## Goal + +Replace periodic automatic scans with event-triggered scans so an idle Password Bridge performs no scheduled CG or AX polling while newly created browser authorization popups retain sub-second scan initiation. + +## Non-goals + +- Do not change target acceptance or trust rules. +- Do not authorize a target from an AX notification, application identity, focus state, title, or window size alone. +- Do not add browser-extension native messaging. +- Do not retain a periodic fallback timer or configurable scan interval. + +## Architecture + +Add an `AccessibilityEventMonitor` responsible only for producing scan wake-up hints. It owns: + +- one `AXObserver` per eligible regular application process; +- `NSWorkspace` observers for application launch, termination, and activation; +- notification registration for AX window creation and focused-window changes; +- window-level title-change and element-destroyed registration when a new window is observed; +- an injectable event sink used to notify `BridgeModel`. + +The monitor never traverses an AX tree, activates a browser, accepts a target, reads a code, or sends keyboard events. Its callback crosses onto the main actor and emits only a process-scoped wake-up hint. + +`BridgeModel` owns event coalescing and the existing single-flight scan state. An event schedules one automatic scan after a 100 millisecond debounce. Further events in the same burst replace the pending debounce rather than starting parallel work. The scan then uses the existing Core Graphics candidate snapshot, application policy, AX warm-up delays, and target acceptance logic unchanged. + +## Target Selection and Security + +An event is not evidence that a window is safe. After a wake-up: + +1. `authorizationWindowCandidates` reads on-screen CG windows and keeps only processes permitted by the active application rule policy. +2. The existing candidate selector prefers a recognized iCloud-password window title; titleless browser candidates continue to require trusted AX origin evidence. +3. `locateTarget` inspects AX windows and applies the existing acceptance checks. +4. A titleless Arc popup must expose an approved extension scheme and host, the exact `/page_popup.html` path, the authorization context, and at least six text inputs. +5. Only a verified target may proceed to code reading and input. + +No event type bypasses this pipeline. Existing AX traversal depth and node limits remain in force. + +## Lifecycle + +At Password Bridge startup, and whenever the menu refreshes permission state, the monitor reconciles its observers with the currently eligible running applications. It performs one initial automatic scan after binding so a popup already open before Password Bridge started can still be found. + +`NSWorkspace` launch events bind observers for newly started eligible applications and trigger one scan. Termination events remove the corresponding observer and stale process state. Activation events reconcile bindings and trigger a debounced scan, covering application switches without polling. + +Changes to monitoring, automatic-fill enablement, application rule mode, or the configured application list immediately reconcile bindings. When monitoring or automatic fill is disabled, all browser AX observers are removed. Re-enabling them performs one reconciliation scan. + +If Accessibility permission is unavailable, binding and automatic scanning stop. A later permission refresh rebuilds observers. + +## Popup Readiness + +Arc may create a titleless window before its extension URL, authorization text, and six fields are ready. A qualifying wake-up therefore keeps the existing bounded warm-up sequence of 120, 160, 220, 300, and 400 milliseconds. After that sequence completes, the app returns to sleep; it does not start an active polling window. + +Every event-triggered automatic scan uses the full bounded sequence. The current candidate-novelty active deadline and fast timer are removed because there is no automatic timer to accelerate. The delay constants may remain as a pure retry policy independent of idle scheduling. + +The existing `scanInProgress` guard remains the final concurrency boundary. If an event arrives while a scan is running, one automatic rescan is remembered and coalesced after the current scan, analogous to the current pending manual request handling. + +## No-Event Behavior + +There is no periodic fallback. If macOS or the browser fails to emit every relevant AX and workspace event, automatic fill may be missed. The user can still use “立即填入” or the global hotkey. A later browser window, focus, activation, or launch event also triggers another scan. + +## User Interface and Persistence + +Remove the “空闲扫描间隔” picker, `IdleScanInterval`, its `BridgeModel` property, and its UserDefaults key. Existing stored values become unused and require no migration. Other settings remain unchanged. + +## Components and Files + +- Add `Sources/ApplePasswordBridge/AccessibilityEventMonitor.swift` for AX and workspace observer lifecycle. +- Modify `BridgeModel.swift` to remove idle/active timer state and replace it with event debounce, observer reconciliation, initial scan, and pending automatic-rescan state. +- Modify `BridgeApp.swift` to remove the interval picker. +- Modify `ApplicationRules.swift` to remove `IdleScanInterval`. +- Update tests to cover pure event coalescing/reconciliation policy and removal of interval scheduling behavior. +- Keep `FirefoxAutofill.swift`, `AuthorizationContext`, and target acceptance code behaviorally unchanged. + +## Testing + +Use protocols or small injectable boundaries so tests do not require live Accessibility notifications. + +- eligible application launch binds once and requests a scan; +- duplicate AX events inside 100 milliseconds coalesce to one scan; +- an event during a running scan produces at most one follow-up automatic scan; +- event-triggered and manual scans use the full bounded readiness sequence without starting a timer; +- application termination removes observer state; +- policy changes reconcile eligible PIDs; +- disabling monitoring or automatic fill removes observers and cancels pending automatic scans; +- enabling after permission is granted performs one initial reconciliation scan; +- manual fill remains immediate and independent of event delivery; +- existing origin, authorization-context, popup-path, and six-field tests remain unchanged and pass. + +Verify the full Swift suite, a Release build, and strict code signing before and after ZIP extraction. + +## Success Criteria + +- No repeating or one-shot idle scan timer remains. +- With no relevant events, Password Bridge schedules no CG candidate snapshots or AX tree traversals. +- Relevant window/application events initiate one coalesced scan after approximately 100 milliseconds. +- Newly created popup scans retain the bounded AX readiness retry sequence. +- Target trust and input validation rules are unchanged. +- Manual fill remains available when an OS event is missed. From 9110dc34697725a5a8dd4d9f27bbd8e519a0f11e Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:12:36 +0800 Subject: [PATCH 35/39] docs: plan event-driven autofill monitoring --- .../plans/2026-09-10-event-driven-autofill.md | 572 ++++++++++++++++++ 1 file changed, 572 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-10-event-driven-autofill.md diff --git a/docs/superpowers/plans/2026-09-10-event-driven-autofill.md b/docs/superpowers/plans/2026-09-10-event-driven-autofill.md new file mode 100644 index 0000000..1405456 --- /dev/null +++ b/docs/superpowers/plans/2026-09-10-event-driven-autofill.md @@ -0,0 +1,572 @@ +# Event-Driven Autofill Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. + +**Goal:** Replace periodic browser scanning with coalesced Accessibility and application lifecycle events while preserving target trust checks and manual fill. + +**Architecture:** Add a main-actor event scheduler/coordinator and an Accessibility monitor composed from a process-observer registry plus system AX/NSWorkspace adapters. BridgeModel binds the monitor to the active application policy, requests one initial scan when bindings become active, and performs full bounded AX readiness retries for each event burst without scheduling idle scans. + +**Tech Stack:** Swift 5.10, AppKit, ApplicationServices AXObserver, NSWorkspace notifications, XCTest, SwiftPM. + +**Spec:** docs/superpowers/specs/2026-09-10-event-driven-autofill-design.md + +## Global Constraints + +- No periodic fallback timer or configurable scan interval remains. +- AX and workspace notifications are wake-up hints only; they never authorize filling. +- Target acceptance, extension origin, popup path, authorization context, six-field minimum, AX depth, and node limits remain behaviorally unchanged. +- Event bursts debounce for 100 milliseconds. +- Event-triggered and manual scans use delays of 120, 160, 220, 300, and 400 milliseconds. +- Manual “立即填入” and the global hotkey remain independent of event delivery. +- Use only system frameworks already linked by SwiftPM. + +--- + +## File Structure + +- Create Sources/ApplePasswordBridge/EventScanCoordinator.swift: debounce, single-flight rerun, monitor binding, and initial-scan ownership. +- Create Sources/ApplePasswordBridge/AccessibilityEventMonitor.swift: AX process observation, registry reconciliation, and NSWorkspace lifecycle conversion. +- Create Tests/ApplePasswordBridgeTests/EventScanCoordinatorTests.swift: deterministic coalescing and initial-scan behavior. +- Create Tests/ApplePasswordBridgeTests/AccessibilityEventMonitorTests.swift: fake observation factory and lifecycle reconciliation. +- Modify Sources/ApplePasswordBridge/BridgeModel.swift: remove timer scheduling and consume event wake-ups. +- Modify Sources/ApplePasswordBridge/BridgeApp.swift: remove the interval picker. +- Modify Sources/ApplePasswordBridge/ApplicationRules.swift: remove IdleScanInterval. +- Modify Sources/ApplePasswordBridge/FirefoxAutofill.swift: expose the existing eligible-application selector without changing its body. +- Modify Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift: replace obsolete timer tests with the event retry policy test. + +### Task 1: Event Scan Scheduler and Coordinator + +**Files:** +- Create: Sources/ApplePasswordBridge/EventScanCoordinator.swift +- Create: Tests/ApplePasswordBridgeTests/EventScanCoordinatorTests.swift + +**Interfaces:** +- Produces: @MainActor protocol AccessibilityEventMonitoring. +- Produces: @MainActor final class EventScanScheduler with request() and cancel(). +- Produces: @MainActor final class EventScanCoordinator with update(policy:enabled:), request(), and stop(). +- Guarantees: one action per pre-run burst; at most one follow-up action for events received during execution; exactly one initial request when observer bindings change. + +- [ ] **Step 1: Write failing scheduler tests** + +Create EventScanCoordinatorTests.swift: + +~~~swift +import XCTest +@testable import ApplePasswordBridge + +@MainActor +final class EventScanCoordinatorTests: XCTestCase { + func test_burst_coalesces_to_one_action() async { + var calls = 0 + let scheduler = EventScanScheduler(delayNanoseconds: 1_000_000) { + calls += 1 + } + scheduler.request() + scheduler.request() + scheduler.request() + try? await Task.sleep(nanoseconds: 20_000_000) + XCTAssertEqual(calls, 1) + } + + func test_cancel_prevents_pending_action() async { + var calls = 0 + let scheduler = EventScanScheduler(delayNanoseconds: 10_000_000) { + calls += 1 + } + scheduler.request() + scheduler.cancel() + try? await Task.sleep(nanoseconds: 30_000_000) + XCTAssertEqual(calls, 0) + } + + func test_changed_enabled_bindings_request_one_initial_action() async { + let monitor = FakeEventMonitor(updateResult: true) + var calls = 0 + let coordinator = EventScanCoordinator( + monitor: monitor, + delayNanoseconds: 1_000_000 + ) { + calls += 1 + } + coordinator.update(policy: makePolicy(), enabled: true) + try? await Task.sleep(nanoseconds: 20_000_000) + XCTAssertEqual(calls, 1) + } +} +~~~ + +FakeEventMonitor implements the protocol, records update calls, exposes emit(), and returns an injected updateResult. makePolicy() returns an allowlist policy containing company.thebrowser.Browser. + +- [ ] **Step 2: Run Task 1 tests and verify RED** + +~~~bash +env DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ +CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-events-task1/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-events-task1/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-events-task1/swiftpm \ +swift test --disable-sandbox \ +--scratch-path /tmp/apple-password-bridge-events-task1/build \ +--filter EventScanCoordinatorTests +~~~ + +Expected: compilation fails because the coordinator types do not exist. + +- [ ] **Step 3: Implement scheduler and coordinator** + +Create EventScanCoordinator.swift with: + +~~~swift +import Foundation + +@MainActor +protocol AccessibilityEventMonitoring: AnyObject { + var onEvent: (() -> Void)? { get set } + func update(policy: ApplicationRulePolicy, enabled: Bool) -> Bool + func stop() +} + +@MainActor +final class EventScanScheduler { + private let delayNanoseconds: UInt64 + private let action: @MainActor () async -> Void + private var pendingTask: Task? + private var actionRunning = false + private var rerunRequested = false + + init( + delayNanoseconds: UInt64 = 100_000_000, + action: @escaping @MainActor () async -> Void + ) { + self.delayNanoseconds = delayNanoseconds + self.action = action + } + + func request() { + if actionRunning { + rerunRequested = true + return + } + pendingTask?.cancel() + pendingTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + try await Task.sleep(nanoseconds: delayNanoseconds) + } catch { + return + } + guard !Task.isCancelled else { return } + pendingTask = nil + actionRunning = true + await action() + actionRunning = false + if rerunRequested { + rerunRequested = false + request() + } + } + } + + func cancel() { + pendingTask?.cancel() + pendingTask = nil + rerunRequested = false + } +} + +@MainActor +final class EventScanCoordinator { + private let monitor: any AccessibilityEventMonitoring + private let scheduler: EventScanScheduler + + init( + monitor: any AccessibilityEventMonitoring, + delayNanoseconds: UInt64 = 100_000_000, + action: @escaping @MainActor () async -> Void + ) { + self.monitor = monitor + scheduler = EventScanScheduler( + delayNanoseconds: delayNanoseconds, + action: action + ) + monitor.onEvent = { [weak scheduler] in scheduler?.request() } + } + + func update(policy: ApplicationRulePolicy, enabled: Bool) { + let changed = monitor.update(policy: policy, enabled: enabled) + if enabled, changed { + scheduler.request() + } else if !enabled { + scheduler.cancel() + } + } + + func request() { scheduler.request() } + + func stop() { + scheduler.cancel() + monitor.stop() + } +} +~~~ + +If Swift rejects weak capture of EventScanScheduler, capture coordinator weakly after initialization or make monitor.onEvent call a private request closure; do not introduce a retain cycle. + +- [ ] **Step 4: Add and pass the in-flight rerun test** + +Add a gate-controlled async test proving several requests during the first action produce exactly one second action. Run the Task 1 command and expect all coordinator tests to pass. + +- [ ] **Step 5: Commit Task 1** + +~~~bash +git add Sources/ApplePasswordBridge/EventScanCoordinator.swift \ +Tests/ApplePasswordBridgeTests/EventScanCoordinatorTests.swift +git commit -m "feat: coalesce event-driven autofill scans" +~~~ + +### Task 2: Accessibility Process Observer Registry + +**Files:** +- Create: Sources/ApplePasswordBridge/AccessibilityEventMonitor.swift +- Create: Tests/ApplePasswordBridgeTests/AccessibilityEventMonitorTests.swift +- Modify: Sources/ApplePasswordBridge/FirefoxAutofill.swift:65-79,185-213 + +**Interfaces:** +- Produces: protocol AccessibilityProcessObservation with cancel(). +- Produces: protocol AccessibilityProcessObservationCreating with makeObservation(processIdentifier:onEvent:). +- Produces: final class AccessibilityObserverRegistry with observedPIDs, reconcile(_:), add(_:), remove(_:), and stop(). +- Consumes: BrowserAutofill.eligibleApplicationsByPID(policy:) moved to internal static scope with its filtering body unchanged. + +- [ ] **Step 1: Write failing registry tests** + +Create fake observation/factory types that record created and cancelled PIDs, then add: + +~~~swift +func test_registry_adds_each_desired_process_once() { + let factory = FakeObservationFactory() + let registry = AccessibilityObserverRegistry(factory: factory) + XCTAssertTrue(registry.reconcile([11, 12])) + XCTAssertFalse(registry.reconcile([11, 12])) + XCTAssertEqual(factory.createdPIDs, [11, 12]) +} + +func test_registry_cancels_removed_processes() { + let factory = FakeObservationFactory() + let registry = AccessibilityObserverRegistry(factory: factory) + _ = registry.reconcile([11, 12]) + XCTAssertTrue(registry.reconcile([12])) + XCTAssertEqual(factory.cancelledPIDs, [11]) +} + +func test_registry_does_not_store_failed_observation() { + let factory = FakeObservationFactory(failingPIDs: [11]) + let registry = AccessibilityObserverRegistry(factory: factory) + XCTAssertFalse(registry.add(11)) + XCTAssertTrue(registry.add(12)) + XCTAssertEqual(registry.observedPIDs, [12]) +} +~~~ + +- [ ] **Step 2: Run Task 2 tests and verify RED** + +~~~bash +env DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ +CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-events-task2/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-events-task2/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-events-task2/swiftpm \ +swift test --disable-sandbox \ +--scratch-path /tmp/apple-password-bridge-events-task2/build \ +--filter AccessibilityEventMonitorTests +~~~ + +Expected: compilation fails because the registry interfaces do not exist. + +- [ ] **Step 3: Implement the registry** + +Registry reconciliation computes obsolete and missing PID sets, cancels obsolete observations, creates missing observations in sorted PID order, and returns true only when the desired set differs. Failed creation is not inserted into observedPIDs. stop() cancels every observation and clears the dictionary. + +- [ ] **Step 4: Reuse eligible application filtering** + +Change the caller to: + +~~~swift +let applications = Self.eligibleApplicationsByPID(policy: policy) +~~~ + +Make eligibleApplicationsByPID internal and static. Move its existing body without changing activation policy, bundle identifier, application policy, or bundle de-duplication conditions. + +- [ ] **Step 5: Verify Task 2 GREEN** + +Run the Task 2 tests, followed by BrowserDiagnosticsTests. Both must pass with zero failures. + +- [ ] **Step 6: Commit Task 2** + +~~~bash +git add Sources/ApplePasswordBridge/AccessibilityEventMonitor.swift \ +Sources/ApplePasswordBridge/FirefoxAutofill.swift \ +Tests/ApplePasswordBridgeTests/AccessibilityEventMonitorTests.swift +git commit -m "feat: reconcile accessibility process observers" +~~~ + +### Task 3: System AX and Workspace Adapters + +**Files:** +- Modify: Sources/ApplePasswordBridge/AccessibilityEventMonitor.swift +- Modify: Tests/ApplePasswordBridgeTests/AccessibilityEventMonitorTests.swift + +**Interfaces:** +- Produces: AXProcessObservation, SystemAccessibilityProcessObservationFactory, AccessibilityEventMonitor, and WorkspaceProcessEvent. +- Observes: AXWindowCreated and AXFocusedWindowChanged on the application element; AXTitleChanged and AXUIElementDestroyed on newly created windows. +- Observes: NSWorkspace didLaunchApplication, didActivateApplication, and didTerminateApplication. + +- [ ] **Step 1: Add failing lifecycle tests** + +Add tests proving: + +~~~swift +func test_enabling_monitor_reconciles_current_processes_without_direct_event() { + let factory = FakeObservationFactory() + let monitor = AccessibilityEventMonitor( + factory: factory, + eligiblePIDs: { _ in [42] }, + observesWorkspace: false + ) + XCTAssertTrue(monitor.update(policy: makePolicy(), enabled: true)) + XCTAssertEqual(factory.createdPIDs, [42]) +} + +func test_eligible_workspace_launch_binds_and_emits_event() { + // Enable monitor with no initial PIDs, inject an eligible regular launch, + // then assert PID creation and one onEvent call. +} + +func test_workspace_termination_removes_without_scan() { + // Bind PID 43, inject termination, then assert cancellation and no event. +} + +func test_disabling_monitor_cancels_all_observers() { + // Bind PID 42, disable, then assert cancellation and changed == true. +} +~~~ + +Use a synthetic WorkspaceProcessEvent containing kind, PID, optional bundle identifier, and regular-activation flag. Tests must not create or depend on live NSRunningApplication instances. + +- [ ] **Step 2: Run lifecycle tests and verify RED** + +Run the Task 2 test command. Expected: missing monitor lifecycle types or behavior. + +- [ ] **Step 3: Implement AXProcessObservation** + +Use the SDK contracts AXObserverCreate, AXObserverAddNotification, AXObserverRemoveNotification, AXObserverGetRunLoopSource, CFRunLoopAddSource, and CFRunLoopRemoveSource. + +Implementation requirements: + +- create AXUIElementCreateApplication(pid); +- register AXWindowCreated and AXFocusedWindowChanged on the app element; +- add the observer source to CFRunLoopGetMain() in common modes; +- on AXWindowCreated, register AXTitleChanged and AXUIElementDestroyed on that window, then emit; +- emit on focused-window and title-change notifications; +- remove tracked notifications and the run-loop source in cancel(); +- treat kAXErrorNotificationAlreadyRegistered as success; +- tolerate unsupported window title/destroy notifications; +- never walk descendants or perform target acceptance in the callback. + +- [ ] **Step 4: Implement AccessibilityEventMonitor** + +update(policy:enabled:) stores the latest policy, starts/stops workspace observers, and reconciles current eligible PIDs. It returns whether the observer set or enabled state changed; it does not emit the initial event because EventScanCoordinator owns that action. + +Launch and activation events add/reconcile and emit only for regular applications allowed by the stored policy. Termination removes the PID without emitting. All callbacks execute on the main actor. + +- [ ] **Step 5: Run Task 3 tests and commit** + +Run AccessibilityEventMonitorTests and the full BrowserDiagnosticsTests. Then: + +~~~bash +git add Sources/ApplePasswordBridge/AccessibilityEventMonitor.swift \ +Tests/ApplePasswordBridgeTests/AccessibilityEventMonitorTests.swift +git commit -m "feat: observe browser accessibility events" +~~~ + +### Task 4: Replace BridgeModel Polling + +**Files:** +- Modify: Sources/ApplePasswordBridge/BridgeModel.swift +- Modify: Sources/ApplePasswordBridge/ApplicationRules.swift +- Modify: Sources/ApplePasswordBridge/BridgeApp.swift +- Modify: Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift + +**Interfaces:** +- Consumes: AccessibilityEventMonitoring and EventScanCoordinator. +- Produces: AccessibilityRetrySchedule.delays, requestEventDrivenScan(), and reconcileEventMonitoring(). +- Removes: scanTimer, activeScanUntil, knownCandidateIdentities, scheduleNextScan(), ScanSchedule interval/deadline helpers, IdleScanInterval, BridgeModel.idleScanInterval, its UserDefaults key, and its Picker. + +- [ ] **Step 1: Write the failing replacement tests** + +Remove obsolete interval/deadline/IdleScanInterval tests. Replace retry-policy tests with: + +~~~swift +func test_event_and_manual_scans_use_full_accessibility_warmup() { + XCTAssertEqual( + AccessibilityRetrySchedule.delays, + [120, 160, 220, 300, 400] + ) +} +~~~ + +Run a source search before implementation: + +~~~bash +rg "IdleScanInterval|idleScanInterval|空闲扫描间隔|scheduleNextScan|scanTimer" \ +Sources Tests +~~~ + +Expected: matches exist before removal. + +- [ ] **Step 2: Run focused tests and verify RED** + +Run BrowserDiagnosticsTests with isolated caches. Expected: compilation fails because AccessibilityRetrySchedule does not exist. + +- [ ] **Step 3: Install the event coordinator in BridgeModel** + +Replace ScanSchedule with: + +~~~swift +struct AccessibilityRetrySchedule { + static let delays: [UInt64] = [120, 160, 220, 300, 400] +} +~~~ + +Inject an AccessibilityEventMonitoring instance into BridgeModel.init with default AccessibilityEventMonitor(). Store it, then lazily create: + +~~~swift +private lazy var eventScanCoordinator = EventScanCoordinator( + monitor: eventMonitor +) { [weak self] in + await self?.scanAndFill(manual: false) +} +~~~ + +At the end of start(), call reconcileEventMonitoring() instead of scheduleNextScan(). requestEventDrivenScan() checks monitoringEnabled, automaticFillEnabled, and accessibilityGranted before forwarding to the coordinator. + +- [ ] **Step 4: Reconcile on state changes** + +The didSet blocks for monitoringEnabled, automaticFillEnabled, and applicationRuleMode persist values and call reconcileEventMonitoring() when started. addApplication and removeApplication reconcile after saving. refreshPermissions reconciles when the Accessibility grant changes. + +Use: + +~~~swift +private func reconcileEventMonitoring() { + let enabled = started + && accessibilityGranted + && monitoringEnabled + && automaticFillEnabled + eventScanCoordinator.update(policy: applicationPolicy, enabled: enabled) +} +~~~ + +- [ ] **Step 5: Remove timer-only state and retry backoff** + +Delete scanTimer, activeScanUntil, knownCandidateIdentities, retryAfter, failedAttempts, scheduleNextScan(), hasNewCandidate(), scheduleRetry(), and all scheduleRetry calls. + +Every event scan uses AccessibilityRetrySchedule.delays. Automatic candidates filter only handledWindows; manual candidates still bypass handled filtering. An event arriving during scanInProgress sets automaticRequestPending. In defer, manual pending runs first; otherwise clear automaticRequestPending and call requestEventDrivenScan(). + +- [ ] **Step 6: Remove the interval setting** + +Delete IdleScanInterval from ApplicationRules.swift, the picker from BridgeApp.swift, and idleScanInterval persistence from BridgeModel.swift. Existing stored idleScanInterval values remain harmless and unused. + +- [ ] **Step 7: Verify polling symbols are gone** + +~~~bash +rg "IdleScanInterval|idleScanInterval|空闲扫描间隔|scheduleNextScan|scanTimer|Timer\.scheduledTimer" \ +Sources Tests +~~~ + +Expected: no matches. Inspect any unrelated Timer match rather than removing it blindly. + +- [ ] **Step 8: Run tests and commit** + +Run BrowserDiagnosticsTests and then the complete Swift test suite with fresh isolated caches. Both must pass. Then: + +~~~bash +git add Sources/ApplePasswordBridge/BridgeModel.swift \ +Sources/ApplePasswordBridge/ApplicationRules.swift \ +Sources/ApplePasswordBridge/BridgeApp.swift \ +Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "perf: replace idle polling with event wake-ups" +~~~ + +### Task 5: Verification and Distributable Build + +**Files:** +- Verify: all source and test files +- Create: DiagnosticBuild/Password-Bridge-Event-Driven.zip + +**Interfaces:** +- Validates the design end to end without relaxing signing or security tests. + +- [ ] **Step 1: Review security-sensitive diffs** + +~~~bash +git diff 25162f6..HEAD -- Sources/ApplePasswordBridge/FirefoxAutofill.swift \ +Sources/ApplePasswordBridge/Accessibility.swift \ +Sources/ApplePasswordBridge/CodeParser.swift +~~~ + +Expected: FirefoxAutofill only exposes the existing eligible-application selector. Accessibility tree collection and CodeParser remain unchanged. + +- [ ] **Step 2: Run a fresh complete suite** + +~~~bash +env DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ +CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-events-final/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-events-final/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-events-final/swiftpm \ +swift test --disable-sandbox \ +--scratch-path /tmp/apple-password-bridge-events-final/test-build +~~~ + +Expected: all tests pass with zero failures. + +- [ ] **Step 3: Build Release** + +~~~bash +env DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ +CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-events-release/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-events-release/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-events-release/swiftpm \ +swift build -c release --disable-sandbox \ +--scratch-path /tmp/apple-password-bridge-events-release/build +~~~ + +Expected: exit status 0 and Build complete. + +- [ ] **Step 4: Build and clean-package the app** + +Run scripts/build-app.sh with DEVELOPER_DIR and isolated caches. If the dist copy acquires Finder metadata, use the signed staging app and copy it with: + +~~~bash +ditto --norsrc --noextattr --noqtn --noacl \ +"/private/var/folders/2t/20n__yqj4w3b13gd45jlmtmw0000gn/T/password-bridge-build-501/Password Bridge.app" \ +"$CLEAN_DIR/Password Bridge.app" +~~~ + +Strictly verify the clean app, archive it as Password-Bridge-Event-Driven.zip, extract it into a second fresh directory, and strictly verify the extracted app. Copy the verified archive to DiagnosticBuild and extract/verify that final file once more. + +- [ ] **Step 5: Manual event matrix** + +1. Leave Arc open without a popup for two minutes; verify Password Bridge CPU stays at 0% between events and no periodic spike appears. +2. Open the iCloud Passwords popup; verify scanning begins in approximately 0.1–0.5 seconds. +3. Close and reopen it; verify a new event triggers another scan. +4. Quit and relaunch Arc; verify observer rebinding. +5. Disable and re-enable automatic fill; verify events stop, then one initial scan occurs. +6. Trigger the global hotkey without a preceding event; verify manual fill still runs. + +Record a browser/OS combination that fails to emit AXWindowCreated as a compatibility limitation. Do not reintroduce polling without a new design decision. + +- [ ] **Step 6: Review repository state** + +~~~bash +git status --short --untracked-files=all +git log -8 --oneline +~~~ + +Expected: source, tests, spec, and plan are committed; the verified ZIP is outside the worktree or intentionally untracked. From c55200e25450a31d7113174f53b8b83c3d2447bf Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:20:11 +0800 Subject: [PATCH 36/39] feat: coalesce event-driven autofill scans --- .../EventScanCoordinator.swift | 105 ++++++++++++ .../EventScanCoordinatorTests.swift | 159 ++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 Sources/ApplePasswordBridge/EventScanCoordinator.swift create mode 100644 Tests/ApplePasswordBridgeTests/EventScanCoordinatorTests.swift diff --git a/Sources/ApplePasswordBridge/EventScanCoordinator.swift b/Sources/ApplePasswordBridge/EventScanCoordinator.swift new file mode 100644 index 0000000..75086b6 --- /dev/null +++ b/Sources/ApplePasswordBridge/EventScanCoordinator.swift @@ -0,0 +1,105 @@ +import Foundation + +@MainActor +protocol AccessibilityEventMonitoring: AnyObject { + var onEvent: (() -> Void)? { get set } + func update(policy: ApplicationRulePolicy, enabled: Bool) -> Bool + func stop() +} + +@MainActor +final class EventScanScheduler { + private let delayNanoseconds: UInt64 + private let action: @MainActor () async -> Void + private var pendingTask: Task? + private var actionRunning = false + private var rerunRequested = false + + init( + delayNanoseconds: UInt64 = 100_000_000, + action: @escaping @MainActor () async -> Void + ) { + self.delayNanoseconds = delayNanoseconds + self.action = action + } + + func request() { + if actionRunning { + rerunRequested = true + return + } + + pendingTask?.cancel() + pendingTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + try await Task.sleep(nanoseconds: delayNanoseconds) + } catch { + return + } + guard !Task.isCancelled else { return } + + pendingTask = nil + actionRunning = true + await action() + actionRunning = false + + if rerunRequested { + rerunRequested = false + request() + } + } + } + + func cancel() { + pendingTask?.cancel() + pendingTask = nil + rerunRequested = false + } +} + +@MainActor +final class EventScanCoordinator { + private let monitor: any AccessibilityEventMonitoring + private let scheduler: EventScanScheduler + private var stopped = false + + init( + monitor: any AccessibilityEventMonitoring, + delayNanoseconds: UInt64 = 100_000_000, + action: @escaping @MainActor () async -> Void + ) { + self.monitor = monitor + let scheduler = EventScanScheduler( + delayNanoseconds: delayNanoseconds, + action: action + ) + self.scheduler = scheduler + monitor.onEvent = { [weak scheduler] in + scheduler?.request() + } + } + + func update(policy: ApplicationRulePolicy, enabled: Bool) { + guard !stopped else { return } + let changed = monitor.update(policy: policy, enabled: enabled) + if enabled, changed { + scheduler.request() + } else if !enabled { + scheduler.cancel() + } + } + + func request() { + guard !stopped else { return } + scheduler.request() + } + + func stop() { + guard !stopped else { return } + stopped = true + monitor.onEvent = nil + scheduler.cancel() + monitor.stop() + } +} diff --git a/Tests/ApplePasswordBridgeTests/EventScanCoordinatorTests.swift b/Tests/ApplePasswordBridgeTests/EventScanCoordinatorTests.swift new file mode 100644 index 0000000..c424d35 --- /dev/null +++ b/Tests/ApplePasswordBridgeTests/EventScanCoordinatorTests.swift @@ -0,0 +1,159 @@ +import XCTest +@testable import ApplePasswordBridge + +@MainActor +private final class FakeEventMonitor: AccessibilityEventMonitoring { + var onEvent: (() -> Void)? + var updateResults: [Bool] + private(set) var updates: [(ApplicationRulePolicy, Bool)] = [] + private(set) var stopCount = 0 + + init(updateResults: [Bool] = []) { + self.updateResults = updateResults + } + + func update(policy: ApplicationRulePolicy, enabled: Bool) -> Bool { + updates.append((policy, enabled)) + return updateResults.isEmpty ? false : updateResults.removeFirst() + } + + func stop() { + stopCount += 1 + } + + func emit() { + onEvent?() + } +} + +@MainActor +final class EventScanCoordinatorTests: XCTestCase { + private func makePolicy() -> ApplicationRulePolicy { + ApplicationRulePolicy( + mode: .allowlist, + allowlist: ["company.thebrowser.Browser"], + denylist: [] + ) + } + + func test_burst_coalesces_to_one_action() async { + var calls = 0 + let scheduler = EventScanScheduler(delayNanoseconds: 1_000_000) { + calls += 1 + } + + scheduler.request() + scheduler.request() + scheduler.request() + try? await Task.sleep(nanoseconds: 30_000_000) + + XCTAssertEqual(calls, 1) + } + + func test_cancel_prevents_pending_action_but_allows_later_requests() async { + var calls = 0 + let scheduler = EventScanScheduler(delayNanoseconds: 10_000_000) { + calls += 1 + } + + scheduler.request() + scheduler.cancel() + try? await Task.sleep(nanoseconds: 30_000_000) + XCTAssertEqual(calls, 0) + + scheduler.request() + try? await Task.sleep(nanoseconds: 30_000_000) + XCTAssertEqual(calls, 1) + } + + func test_requests_during_action_coalesce_to_one_follow_up() async { + var calls = 0 + var scheduler: EventScanScheduler! + scheduler = EventScanScheduler(delayNanoseconds: 1_000_000) { + calls += 1 + if calls == 1 { + scheduler.request() + scheduler.request() + scheduler.request() + try? await Task.sleep(nanoseconds: 5_000_000) + } + } + + scheduler.request() + try? await Task.sleep(nanoseconds: 40_000_000) + + XCTAssertEqual(calls, 2) + } + + func test_changed_enabled_bindings_request_one_initial_action() async { + let monitor = FakeEventMonitor(updateResults: [true]) + var calls = 0 + let coordinator = EventScanCoordinator( + monitor: monitor, + delayNanoseconds: 1_000_000 + ) { + calls += 1 + } + + coordinator.update(policy: makePolicy(), enabled: true) + try? await Task.sleep(nanoseconds: 30_000_000) + + XCTAssertEqual(calls, 1) + } + + func test_unchanged_bindings_do_not_request_another_initial_action() async { + let monitor = FakeEventMonitor(updateResults: [true, false]) + var calls = 0 + let coordinator = EventScanCoordinator( + monitor: monitor, + delayNanoseconds: 1_000_000 + ) { + calls += 1 + } + + coordinator.update(policy: makePolicy(), enabled: true) + try? await Task.sleep(nanoseconds: 20_000_000) + coordinator.update(policy: makePolicy(), enabled: true) + try? await Task.sleep(nanoseconds: 20_000_000) + + XCTAssertEqual(calls, 1) + } + + func test_monitor_events_use_the_same_debounced_scheduler() async { + let monitor = FakeEventMonitor() + var calls = 0 + let coordinator = EventScanCoordinator( + monitor: monitor, + delayNanoseconds: 1_000_000 + ) { + calls += 1 + } + + monitor.emit() + monitor.emit() + monitor.emit() + try? await Task.sleep(nanoseconds: 30_000_000) + + XCTAssertEqual(calls, 1) + withExtendedLifetime(coordinator) {} + } + + func test_stop_cancels_pending_action_and_ignores_late_events() async { + let monitor = FakeEventMonitor(updateResults: [true]) + var calls = 0 + let coordinator = EventScanCoordinator( + monitor: monitor, + delayNanoseconds: 10_000_000 + ) { + calls += 1 + } + + coordinator.update(policy: makePolicy(), enabled: true) + coordinator.stop() + monitor.emit() + try? await Task.sleep(nanoseconds: 30_000_000) + + XCTAssertEqual(calls, 0) + XCTAssertEqual(monitor.stopCount, 1) + } +} From e733f41504fae242704ff67a772121c753ba13d5 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:29:22 +0800 Subject: [PATCH 37/39] feat: observe browser accessibility events --- .../AccessibilityEventMonitor.swift | 346 ++++++++++++++++++ .../ApplePasswordBridge/FirefoxAutofill.swift | 4 +- .../AccessibilityEventMonitorTests.swift | 185 ++++++++++ 3 files changed, 533 insertions(+), 2 deletions(-) create mode 100644 Sources/ApplePasswordBridge/AccessibilityEventMonitor.swift create mode 100644 Tests/ApplePasswordBridgeTests/AccessibilityEventMonitorTests.swift diff --git a/Sources/ApplePasswordBridge/AccessibilityEventMonitor.swift b/Sources/ApplePasswordBridge/AccessibilityEventMonitor.swift new file mode 100644 index 0000000..badb4e0 --- /dev/null +++ b/Sources/ApplePasswordBridge/AccessibilityEventMonitor.swift @@ -0,0 +1,346 @@ +import AppKit +import ApplicationServices +import Foundation + +@MainActor +protocol AccessibilityProcessObservation: AnyObject { + func cancel() +} + +@MainActor +protocol AccessibilityProcessObservationCreating: AnyObject { + func makeObservation( + processIdentifier: pid_t, + onEvent: @escaping @MainActor () -> Void + ) -> (any AccessibilityProcessObservation)? +} + +@MainActor +final class AccessibilityObserverRegistry { + private let factory: any AccessibilityProcessObservationCreating + private let onEvent: @MainActor () -> Void + private var observations: [pid_t: any AccessibilityProcessObservation] = [:] + + init( + factory: any AccessibilityProcessObservationCreating, + onEvent: @escaping @MainActor () -> Void = {} + ) { + self.factory = factory + self.onEvent = onEvent + } + + var observedPIDs: Set { + Set(observations.keys) + } + + @discardableResult + func reconcile(_ desiredPIDs: Set) -> Bool { + let before = observedPIDs + for processIdentifier in before.subtracting(desiredPIDs).sorted() { + _ = remove(processIdentifier) + } + for processIdentifier in desiredPIDs.subtracting(before).sorted() { + _ = add(processIdentifier) + } + return before != observedPIDs + } + + @discardableResult + func add(_ processIdentifier: pid_t) -> Bool { + guard observations[processIdentifier] == nil, + let observation = factory.makeObservation( + processIdentifier: processIdentifier, + onEvent: onEvent + ) else { + return false + } + observations[processIdentifier] = observation + return true + } + + @discardableResult + func remove(_ processIdentifier: pid_t) -> Bool { + guard let observation = observations.removeValue(forKey: processIdentifier) else { + return false + } + observation.cancel() + return true + } + + func stop() { + for processIdentifier in observations.keys.sorted() { + _ = remove(processIdentifier) + } + } +} + +enum WorkspaceProcessEventKind: Sendable { + case launched + case activated + case terminated +} + +struct WorkspaceProcessEvent: Sendable { + let kind: WorkspaceProcessEventKind + let processIdentifier: pid_t + let bundleIdentifier: String? + let isRegularActivation: Bool +} + +@MainActor +final class AccessibilityEventMonitor: AccessibilityEventMonitoring { + var onEvent: (() -> Void)? + + private let eligiblePIDs: (ApplicationRulePolicy) -> Set + private let observesWorkspace: Bool + private let workspaceCenter: NotificationCenter + private lazy var registry = AccessibilityObserverRegistry(factory: factory) { [weak self] in + self?.onEvent?() + } + private let factory: any AccessibilityProcessObservationCreating + private var workspaceTokens: [NSObjectProtocol] = [] + private var policy: ApplicationRulePolicy? + private var enabled = false + + init( + factory: (any AccessibilityProcessObservationCreating)? = nil, + eligiblePIDs: @escaping (ApplicationRulePolicy) -> Set = { policy in + Set(BrowserAutofill.eligibleApplicationsByPID(policy: policy).keys) + }, + observesWorkspace: Bool = true, + workspaceCenter: NotificationCenter = NSWorkspace.shared.notificationCenter + ) { + self.factory = factory ?? SystemAccessibilityProcessObservationFactory() + self.eligiblePIDs = eligiblePIDs + self.observesWorkspace = observesWorkspace + self.workspaceCenter = workspaceCenter + } + + func update(policy: ApplicationRulePolicy, enabled: Bool) -> Bool { + let enabledChanged = self.enabled != enabled + let policyChanged = self.policy.map { + $0.mode != policy.mode + || $0.allowlist != policy.allowlist + || $0.denylist != policy.denylist + } ?? true + self.policy = policy + self.enabled = enabled + + guard enabled else { + let hadObservers = !registry.observedPIDs.isEmpty + stopWorkspaceObservation() + registry.stop() + return enabledChanged || hadObservers + } + + startWorkspaceObservationIfNeeded() + let bindingsChanged = registry.reconcile(eligiblePIDs(policy)) + return enabledChanged || policyChanged || bindingsChanged + } + + func stop() { + enabled = false + stopWorkspaceObservation() + registry.stop() + } + + func handleWorkspaceEvent(_ event: WorkspaceProcessEvent) { + guard enabled else { return } + switch event.kind { + case .terminated: + _ = registry.remove(event.processIdentifier) + case .launched, .activated: + guard event.isRegularActivation, + let bundleIdentifier = event.bundleIdentifier, + let policy, + policy.permits(bundleIdentifier: bundleIdentifier) else { + return + } + _ = registry.add(event.processIdentifier) + onEvent?() + } + } + + private func startWorkspaceObservationIfNeeded() { + guard observesWorkspace, workspaceTokens.isEmpty else { return } + observeWorkspace(NSWorkspace.didLaunchApplicationNotification, kind: .launched) + observeWorkspace(NSWorkspace.didActivateApplicationNotification, kind: .activated) + observeWorkspace(NSWorkspace.didTerminateApplicationNotification, kind: .terminated) + } + + private func observeWorkspace( + _ name: Notification.Name, + kind: WorkspaceProcessEventKind + ) { + let token = workspaceCenter.addObserver( + forName: name, + object: nil, + queue: .main + ) { [weak self] notification in + guard let application = notification.userInfo?[NSWorkspace.applicationUserInfoKey] + as? NSRunningApplication else { + return + } + let event = WorkspaceProcessEvent( + kind: kind, + processIdentifier: application.processIdentifier, + bundleIdentifier: application.bundleIdentifier, + isRegularActivation: application.activationPolicy == .regular + ) + Task { @MainActor [weak self] in + self?.handleWorkspaceEvent(event) + } + } + workspaceTokens.append(token) + } + + private func stopWorkspaceObservation() { + for token in workspaceTokens { + workspaceCenter.removeObserver(token) + } + workspaceTokens.removeAll() + } +} + +@MainActor +final class SystemAccessibilityProcessObservationFactory: AccessibilityProcessObservationCreating { + func makeObservation( + processIdentifier: pid_t, + onEvent: @escaping @MainActor () -> Void + ) -> (any AccessibilityProcessObservation)? { + AXProcessObservation( + processIdentifier: processIdentifier, + onEvent: onEvent + ) + } +} + +@MainActor +private final class AXObserverCallbackContext { + weak var observation: AXProcessObservation? +} + +private func accessibilityObserverCallback( + _ observer: AXObserver, + _ element: AXUIElement, + _ notification: CFString, + _ reference: UnsafeMutableRawPointer? +) { + guard let reference else { return } + MainActor.assumeIsolated { + let context = Unmanaged + .fromOpaque(reference) + .takeUnretainedValue() + context.observation?.receive(element: element, notification: notification as String) + } +} + +@MainActor +final class AXProcessObservation: AccessibilityProcessObservation { + private struct Registration { + let element: AXUIElement + let notification: String + } + + private let observer: AXObserver + private let application: AXUIElement + private let context: AXObserverCallbackContext + private let contextPointer: UnsafeMutableRawPointer + private let onEvent: @MainActor () -> Void + private var registrations: [Registration] = [] + private var cancelled = false + + init?( + processIdentifier: pid_t, + onEvent: @escaping @MainActor () -> Void + ) { + var createdObserver: AXObserver? + guard AXObserverCreate( + processIdentifier, + accessibilityObserverCallback, + &createdObserver + ) == .success, + let createdObserver else { + return nil + } + + let context = AXObserverCallbackContext() + self.observer = createdObserver + self.application = AXUIElementCreateApplication(processIdentifier) + self.context = context + self.contextPointer = Unmanaged.passRetained(context).toOpaque() + self.onEvent = onEvent + context.observation = self + + CFRunLoopAddSource( + CFRunLoopGetMain(), + AXObserverGetRunLoopSource(createdObserver), + .commonModes + ) + + let observesWindowCreation = register( + element: application, + notification: kAXWindowCreatedNotification + ) + let observesFocusChange = register( + element: application, + notification: kAXFocusedWindowChangedNotification + ) + guard observesWindowCreation || observesFocusChange else { + cancel() + return nil + } + } + + func receive(element: AXUIElement, notification: String) { + guard !cancelled else { return } + if notification == kAXWindowCreatedNotification { + _ = register(element: element, notification: kAXTitleChangedNotification) + _ = register(element: element, notification: kAXUIElementDestroyedNotification) + onEvent() + } else if notification == kAXUIElementDestroyedNotification { + registrations.removeAll { CFEqual($0.element, element) } + } else if notification == kAXFocusedWindowChangedNotification + || notification == kAXTitleChangedNotification { + onEvent() + } + } + + func cancel() { + guard !cancelled else { return } + cancelled = true + for registration in registrations.reversed() { + _ = AXObserverRemoveNotification( + observer, + registration.element, + registration.notification as CFString + ) + } + registrations.removeAll() + CFRunLoopRemoveSource( + CFRunLoopGetMain(), + AXObserverGetRunLoopSource(observer), + .commonModes + ) + context.observation = nil + Unmanaged.fromOpaque(contextPointer).release() + } + + private func register(element: AXUIElement, notification: String) -> Bool { + let result = AXObserverAddNotification( + observer, + element, + notification as CFString, + contextPointer + ) + guard result == .success || result == .notificationAlreadyRegistered else { + return false + } + if !registrations.contains(where: { + CFEqual($0.element, element) && $0.notification == notification + }) { + registrations.append(Registration(element: element, notification: notification)) + } + return true + } +} diff --git a/Sources/ApplePasswordBridge/FirefoxAutofill.swift b/Sources/ApplePasswordBridge/FirefoxAutofill.swift index 4042551..789caf7 100644 --- a/Sources/ApplePasswordBridge/FirefoxAutofill.swift +++ b/Sources/ApplePasswordBridge/FirefoxAutofill.swift @@ -63,7 +63,7 @@ final class BrowserAutofill { } func authorizationWindowCandidates(policy: ApplicationRulePolicy) -> [Candidate] { - let applications = eligibleApplicationsByPID(policy: policy) + let applications = Self.eligibleApplicationsByPID(policy: policy) guard !applications.isEmpty else { return [] } let windowInfo = CGWindowListCopyWindowInfo( @@ -182,7 +182,7 @@ final class BrowserAutofill { } } - private func eligibleApplicationsByPID( + static func eligibleApplicationsByPID( policy: ApplicationRulePolicy ) -> [pid_t: NSRunningApplication] { var unique: [String: NSRunningApplication] = [:] diff --git a/Tests/ApplePasswordBridgeTests/AccessibilityEventMonitorTests.swift b/Tests/ApplePasswordBridgeTests/AccessibilityEventMonitorTests.swift new file mode 100644 index 0000000..e7028ee --- /dev/null +++ b/Tests/ApplePasswordBridgeTests/AccessibilityEventMonitorTests.swift @@ -0,0 +1,185 @@ +import XCTest +@testable import ApplePasswordBridge + +@MainActor +private final class FakeProcessObservation: AccessibilityProcessObservation { + let processIdentifier: pid_t + private let onCancel: (pid_t) -> Void + private var cancelled = false + + init(processIdentifier: pid_t, onCancel: @escaping (pid_t) -> Void) { + self.processIdentifier = processIdentifier + self.onCancel = onCancel + } + + func cancel() { + guard !cancelled else { return } + cancelled = true + onCancel(processIdentifier) + } +} + +@MainActor +private final class FakeProcessObservationFactory: AccessibilityProcessObservationCreating { + let failingPIDs: Set + private(set) var createdPIDs: [pid_t] = [] + private(set) var cancelledPIDs: [pid_t] = [] + + init(failingPIDs: Set = []) { + self.failingPIDs = failingPIDs + } + + func makeObservation( + processIdentifier: pid_t, + onEvent: @escaping @MainActor () -> Void + ) -> (any AccessibilityProcessObservation)? { + guard !failingPIDs.contains(processIdentifier) else { return nil } + createdPIDs.append(processIdentifier) + return FakeProcessObservation(processIdentifier: processIdentifier) { [weak self] pid in + self?.cancelledPIDs.append(pid) + } + } +} + +@MainActor +final class AccessibilityEventMonitorTests: XCTestCase { + private func makePolicy() -> ApplicationRulePolicy { + ApplicationRulePolicy( + mode: .allowlist, + allowlist: ["company.thebrowser.Browser"], + denylist: [] + ) + } + + func test_registry_adds_each_desired_process_once() { + let factory = FakeProcessObservationFactory() + let registry = AccessibilityObserverRegistry(factory: factory) + + XCTAssertTrue(registry.reconcile([11, 12])) + XCTAssertFalse(registry.reconcile([11, 12])) + XCTAssertEqual(factory.createdPIDs, [11, 12]) + XCTAssertEqual(registry.observedPIDs, [11, 12]) + } + + func test_registry_cancels_removed_processes() { + let factory = FakeProcessObservationFactory() + let registry = AccessibilityObserverRegistry(factory: factory) + + _ = registry.reconcile([11, 12]) + XCTAssertTrue(registry.reconcile([12])) + + XCTAssertEqual(factory.cancelledPIDs, [11]) + XCTAssertEqual(registry.observedPIDs, [12]) + } + + func test_registry_does_not_store_failed_observation() { + let factory = FakeProcessObservationFactory(failingPIDs: [11]) + let registry = AccessibilityObserverRegistry(factory: factory) + + XCTAssertFalse(registry.add(11)) + XCTAssertTrue(registry.add(12)) + XCTAssertEqual(registry.observedPIDs, [12]) + } + + func test_enabling_monitor_reconciles_current_processes_without_direct_event() { + let factory = FakeProcessObservationFactory() + let monitor = AccessibilityEventMonitor( + factory: factory, + eligiblePIDs: { _ in [42] }, + observesWorkspace: false + ) + var eventCount = 0 + monitor.onEvent = { eventCount += 1 } + + XCTAssertTrue(monitor.update(policy: makePolicy(), enabled: true)) + + XCTAssertEqual(factory.createdPIDs, [42]) + XCTAssertEqual(eventCount, 0) + } + + func test_eligible_workspace_launch_binds_and_emits_event() { + let factory = FakeProcessObservationFactory() + let monitor = AccessibilityEventMonitor( + factory: factory, + eligiblePIDs: { _ in [] }, + observesWorkspace: false + ) + var eventCount = 0 + monitor.onEvent = { eventCount += 1 } + _ = monitor.update(policy: makePolicy(), enabled: true) + + monitor.handleWorkspaceEvent( + WorkspaceProcessEvent( + kind: .launched, + processIdentifier: 43, + bundleIdentifier: "company.thebrowser.Browser", + isRegularActivation: true + ) + ) + + XCTAssertEqual(factory.createdPIDs, [43]) + XCTAssertEqual(eventCount, 1) + } + + func test_ineligible_workspace_event_does_not_bind_or_emit() { + let factory = FakeProcessObservationFactory() + let monitor = AccessibilityEventMonitor( + factory: factory, + eligiblePIDs: { _ in [] }, + observesWorkspace: false + ) + var eventCount = 0 + monitor.onEvent = { eventCount += 1 } + _ = monitor.update(policy: makePolicy(), enabled: true) + + monitor.handleWorkspaceEvent( + WorkspaceProcessEvent( + kind: .activated, + processIdentifier: 44, + bundleIdentifier: "com.example.Other", + isRegularActivation: true + ) + ) + + XCTAssertTrue(factory.createdPIDs.isEmpty) + XCTAssertEqual(eventCount, 0) + } + + func test_workspace_termination_removes_without_scan() { + let factory = FakeProcessObservationFactory() + let monitor = AccessibilityEventMonitor( + factory: factory, + eligiblePIDs: { _ in [43] }, + observesWorkspace: false + ) + var eventCount = 0 + monitor.onEvent = { eventCount += 1 } + _ = monitor.update(policy: makePolicy(), enabled: true) + + monitor.handleWorkspaceEvent( + WorkspaceProcessEvent( + kind: .terminated, + processIdentifier: 43, + bundleIdentifier: "company.thebrowser.Browser", + isRegularActivation: true + ) + ) + + XCTAssertEqual(factory.cancelledPIDs, [43]) + XCTAssertEqual(eventCount, 0) + } + + func test_disabling_monitor_cancels_all_observers() { + let factory = FakeProcessObservationFactory() + let monitor = AccessibilityEventMonitor( + factory: factory, + eligiblePIDs: { _ in [42] }, + observesWorkspace: false + ) + _ = monitor.update(policy: makePolicy(), enabled: true) + + XCTAssertTrue(monitor.update(policy: makePolicy(), enabled: false)) + + XCTAssertEqual(factory.cancelledPIDs, [42]) + } +} From 464317c91d135f4184086f7067af813862e003de Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:41:22 +0800 Subject: [PATCH 38/39] perf: replace idle polling with event wake-ups --- .../AccessibilityEventMonitor.swift | 2 + .../ApplicationRules.swift | 14 -- Sources/ApplePasswordBridge/BridgeApp.swift | 13 -- Sources/ApplePasswordBridge/BridgeModel.swift | 178 +++++++----------- .../EventScanCoordinator.swift | 11 +- .../BrowserDiagnosticsTests.swift | 91 +-------- .../EventScanCoordinatorTests.swift | 50 ++++- 7 files changed, 133 insertions(+), 226 deletions(-) diff --git a/Sources/ApplePasswordBridge/AccessibilityEventMonitor.swift b/Sources/ApplePasswordBridge/AccessibilityEventMonitor.swift index badb4e0..6788ab0 100644 --- a/Sources/ApplePasswordBridge/AccessibilityEventMonitor.swift +++ b/Sources/ApplePasswordBridge/AccessibilityEventMonitor.swift @@ -72,6 +72,7 @@ final class AccessibilityObserverRegistry { _ = remove(processIdentifier) } } + } enum WorkspaceProcessEventKind: Sendable { @@ -144,6 +145,7 @@ final class AccessibilityEventMonitor: AccessibilityEventMonitoring { registry.stop() } + func handleWorkspaceEvent(_ event: WorkspaceProcessEvent) { guard enabled else { return } switch event.kind { diff --git a/Sources/ApplePasswordBridge/ApplicationRules.swift b/Sources/ApplePasswordBridge/ApplicationRules.swift index b7976a5..c00dda6 100644 --- a/Sources/ApplePasswordBridge/ApplicationRules.swift +++ b/Sources/ApplePasswordBridge/ApplicationRules.swift @@ -24,20 +24,6 @@ enum FillSpeed: String, CaseIterable, Identifiable { } } -enum IdleScanInterval: Double, CaseIterable, Identifiable { - case seconds3 = 3 - case seconds5 = 5 - case seconds10 = 10 - case seconds15 = 15 - case seconds30 = 30 - - static let defaultValue: IdleScanInterval = .seconds15 - - var id: Double { rawValue } - var seconds: TimeInterval { rawValue } - var title: String { "\(Int(rawValue)) 秒" } -} - enum ApplicationRuleMode: String, CaseIterable, Identifiable, Sendable { case allowlist case denylist diff --git a/Sources/ApplePasswordBridge/BridgeApp.swift b/Sources/ApplePasswordBridge/BridgeApp.swift index ea0cb55..52790c3 100644 --- a/Sources/ApplePasswordBridge/BridgeApp.swift +++ b/Sources/ApplePasswordBridge/BridgeApp.swift @@ -56,19 +56,6 @@ private struct BridgeMenu: View { .pickerStyle(.segmented) } - VStack(alignment: .leading, spacing: 8) { - Text("空闲扫描间隔") - .font(.caption) - .foregroundStyle(.secondary) - Picker("空闲扫描间隔", selection: $model.idleScanInterval) { - ForEach(IdleScanInterval.allCases) { interval in - Text(interval.title).tag(interval) - } - } - .labelsHidden() - .pickerStyle(.segmented) - } - Button { Task { await model.fillNow() } } label: { diff --git a/Sources/ApplePasswordBridge/BridgeModel.swift b/Sources/ApplePasswordBridge/BridgeModel.swift index 8062883..126c4f8 100644 --- a/Sources/ApplePasswordBridge/BridgeModel.swift +++ b/Sources/ApplePasswordBridge/BridgeModel.swift @@ -3,29 +3,8 @@ import Combine import ServiceManagement import UniformTypeIdentifiers -struct ScanSchedule { - static let activeInterval: TimeInterval = 0.25 - static let fullAccessibilityRetryDelays: [UInt64] = [120, 160, 220, 300, 400] - static let stableAccessibilityRetryDelays: [UInt64] = [120] - - static func interval( - now: Date, - activeUntil: Date?, - idleInterval: TimeInterval - ) -> TimeInterval { - guard let activeUntil, activeUntil > now else { return idleInterval } - return activeInterval - } - - static func activeUntil(now: Date, hasNewCandidate: Bool) -> Date? { - hasNewCandidate ? now.addingTimeInterval(2) : nil - } - - static func accessibilityRetryDelays(manual: Bool, hasNewCandidate: Bool) -> [UInt64] { - manual || hasNewCandidate - ? fullAccessibilityRetryDelays - : stableAccessibilityRetryDelays - } +struct AccessibilityRetrySchedule { + static let delays: [UInt64] = [120, 160, 220, 300, 400] } private final class DiagnosticsTaskBox: @unchecked Sendable { @@ -53,10 +32,16 @@ private final class DiagnosticsTaskBox: @unchecked Sendable { @MainActor final class BridgeModel: ObservableObject { @Published var monitoringEnabled: Bool { - didSet { UserDefaults.standard.set(monitoringEnabled, forKey: Keys.monitoring) } + didSet { + UserDefaults.standard.set(monitoringEnabled, forKey: Keys.monitoring) + if started { reconcileEventMonitoring() } + } } @Published var automaticFillEnabled: Bool { - didSet { UserDefaults.standard.set(automaticFillEnabled, forKey: Keys.automaticFill) } + didSet { + UserDefaults.standard.set(automaticFillEnabled, forKey: Keys.automaticFill) + if started { reconcileEventMonitoring() } + } } @Published var ocrFallbackEnabled: Bool { didSet { UserDefaults.standard.set(ocrFallbackEnabled, forKey: Keys.ocrFallback) } @@ -64,15 +49,12 @@ final class BridgeModel: ObservableObject { @Published var fillSpeed: FillSpeed { didSet { UserDefaults.standard.set(fillSpeed.rawValue, forKey: Keys.fillSpeed) } } - @Published var idleScanInterval: IdleScanInterval { + @Published var applicationRuleMode: ApplicationRuleMode { didSet { - UserDefaults.standard.set(idleScanInterval.rawValue, forKey: Keys.idleScanInterval) - if started { scheduleNextScan() } + UserDefaults.standard.set(applicationRuleMode.rawValue, forKey: Keys.applicationRuleMode) + if started { reconcileEventMonitoring() } } } - @Published var applicationRuleMode: ApplicationRuleMode { - didSet { UserDefaults.standard.set(applicationRuleMode.rawValue, forKey: Keys.applicationRuleMode) } - } @Published private(set) var allowlistedApplications: [TargetApplication] @Published private(set) var denylistedApplications: [TargetApplication] @Published private(set) var accessibilityGranted = false @@ -88,7 +70,6 @@ final class BridgeModel: ObservableObject { static let automaticFill = "automaticFillEnabled" static let ocrFallback = "ocrFallbackEnabled" static let fillSpeed = "fillSpeed" - static let idleScanInterval = "idleScanInterval" static let applicationRuleMode = "applicationRuleMode" static let allowlistedApplications = "allowlistedApplications" static let denylistedApplications = "denylistedApplications" @@ -97,27 +78,32 @@ final class BridgeModel: ObservableObject { private let codeReader = PasswordCodeReader() private let browserAutofill = BrowserAutofill() private let browserDiagnostics: any BrowserDiagnosticsRunning - private var scanTimer: Timer? + private let eventMonitor: any AccessibilityEventMonitoring + private lazy var eventScanCoordinator = EventScanCoordinator(monitor: eventMonitor) { [weak self] in + await self?.scanAndFill(manual: false) + } + private var terminationObserver: NSObjectProtocol? private var hotKey: GlobalHotKey? private var currentCode: CapturedCode? private var lastSuccessfulCode: CapturedCode? private var handledWindows: [BrowserAutofill.WindowIdentity: Date] = [:] - private var retryAfter: [BrowserAutofill.WindowIdentity: Date] = [:] - private var failedAttempts: [BrowserAutofill.WindowIdentity: Int] = [:] private var scanInProgress = false private var manualRequestPending = false + private var automaticRequestPending = false private var started = false - private var knownCandidateIdentities: Set = [] - private var activeScanUntil: Date? - init(browserDiagnostics: any BrowserDiagnosticsRunning = BrowserDiagnostics(), startAutomatically: Bool = true) { + init( + browserDiagnostics: any BrowserDiagnosticsRunning = BrowserDiagnostics(), + eventMonitor: (any AccessibilityEventMonitoring)? = nil, + startAutomatically: Bool = true + ) { self.browserDiagnostics = browserDiagnostics + self.eventMonitor = eventMonitor ?? AccessibilityEventMonitor() UserDefaults.standard.register(defaults: [ Keys.monitoring: true, Keys.automaticFill: true, Keys.ocrFallback: true, Keys.fillSpeed: FillSpeed.reliable.rawValue, - Keys.idleScanInterval: IdleScanInterval.defaultValue.rawValue, Keys.applicationRuleMode: ApplicationRuleMode.allowlist.rawValue ]) monitoringEnabled = UserDefaults.standard.bool(forKey: Keys.monitoring) @@ -126,9 +112,6 @@ final class BridgeModel: ObservableObject { fillSpeed = FillSpeed( rawValue: UserDefaults.standard.string(forKey: Keys.fillSpeed) ?? "" ) ?? .reliable - idleScanInterval = IdleScanInterval( - rawValue: UserDefaults.standard.double(forKey: Keys.idleScanInterval) - ) ?? .defaultValue applicationRuleMode = ApplicationRuleMode( rawValue: UserDefaults.standard.string(forKey: Keys.applicationRuleMode) ?? "" ) ?? .allowlist @@ -140,11 +123,26 @@ final class BridgeModel: ObservableObject { key: Keys.denylistedApplications, fallback: [] ) + terminationObserver = NotificationCenter.default.addObserver( + forName: NSApplication.willTerminateNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.eventScanCoordinator.stop() + } + } if startAutomatically { Task { @MainActor [weak self] in self?.start() } } } + deinit { + if let terminationObserver { + NotificationCenter.default.removeObserver(terminationObserver) + } + } + func runDiagnostics() async { guard !isDiagnosing else { return } isDiagnosing = true @@ -194,41 +192,13 @@ final class BridgeModel: ObservableObject { let hotKeyReady = hotKey?.start() == true statusText = hotKeyReady ? "等待 Apple 密码授权窗口" : "快捷键注册失败" - scheduleNextScan() + reconcileEventMonitoring() } func fillNow() async { await scanAndFill(manual: true) } - private func scheduleNextScan() { - scanTimer?.invalidate() - let now = Date() - let interval = ScanSchedule.interval( - now: now, - activeUntil: activeScanUntil, - idleInterval: idleScanInterval.seconds - ) - let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in - Task { @MainActor [weak self] in - guard let self else { return } - if self.monitoringEnabled && self.automaticFillEnabled { - await self.scanAndFill(manual: false) - } - self.scheduleNextScan() - } - } - timer.tolerance = interval == ScanSchedule.activeInterval ? 0.05 : 0.4 - scanTimer = timer - } - - static func hasNewCandidate( - _ candidates: Set, - previous: Set - ) -> Bool { - !candidates.isSubset(of: previous) - } - func requestAccessibility() { PermissionManager.requestAccessibility() statusText = "请在系统设置中允许辅助功能权限" @@ -300,11 +270,16 @@ final class BridgeModel: ObservableObject { denylistedApplications.removeAll { $0.bundleIdentifier == application.bundleIdentifier } saveApplications(denylistedApplications, key: Keys.denylistedApplications) } + reconcileEventMonitoring() } private func scanAndFill(manual: Bool) async { guard !scanInProgress else { - if manual { manualRequestPending = true } + if manual { + manualRequestPending = true + } else { + automaticRequestPending = true + } return } guard accessibilityGranted else { @@ -315,26 +290,12 @@ final class BridgeModel: ObservableObject { let allCandidates = browserAutofill.authorizationWindowCandidates(policy: applicationPolicy) let visibleIdentities = Set(allCandidates.map(\.identity)) let now = Date() - let hasNewCandidate = Self.hasNewCandidate( - visibleIdentities, - previous: knownCandidateIdentities - ) - if let activeUntil = ScanSchedule.activeUntil( - now: now, - hasNewCandidate: hasNewCandidate - ) { - activeScanUntil = activeUntil - } - knownCandidateIdentities = visibleIdentities handledWindows = handledWindows.filter { visibleIdentities.contains($0.key) && now.timeIntervalSince($0.value) < 10 } - retryAfter = retryAfter.filter { visibleIdentities.contains($0.key) } - failedAttempts = failedAttempts.filter { visibleIdentities.contains($0.key) } let candidates = manual ? allCandidates : allCandidates.filter { handledWindows[$0.identity] == nil - && (retryAfter[$0.identity] ?? .distantPast) <= now } guard !candidates.isEmpty else { if manual { statusText = AutofillFailure.authorizationWindowNotFound.localizedDescription } @@ -351,6 +312,9 @@ final class BridgeModel: ObservableObject { Task { @MainActor [weak self] in await self?.scanAndFill(manual: true) } + } else if automaticRequestPending { + automaticRequestPending = false + requestEventDrivenScan() } } @@ -358,13 +322,9 @@ final class BridgeModel: ObservableObject { do { target = try await locateTargetAfterAccessibilityWarmup( candidates: candidates, - retryDelays: ScanSchedule.accessibilityRetryDelays( - manual: manual, - hasNewCandidate: hasNewCandidate - ) + retryDelays: AccessibilityRetrySchedule.delays ) } catch { - scheduleRetry(for: candidates.map(\.identity)) if manual { statusText = error.localizedDescription } return } @@ -381,7 +341,6 @@ final class BridgeModel: ObservableObject { } guard let code = currentCode, code.isFresh() else { currentCode = nil - scheduleRetry(for: [target.retryIdentity]) if manual { statusText = "未发现有效的 Apple 密码验证码窗口" } return } @@ -405,7 +364,6 @@ final class BridgeModel: ObservableObject { markHandled(target.retryIdentity) statusText = "已填入 \(target.application.localizedName ?? "目标应用")(\(code.source.rawValue))" } catch { - scheduleRetry(for: [target.retryIdentity]) statusText = error.localizedDescription } } @@ -428,20 +386,8 @@ final class BridgeModel: ObservableObject { throw lastError } - private func scheduleRetry(for identities: [BrowserAutofill.WindowIdentity]) { - let now = Date() - for identity in identities { - let attempt = min((failedAttempts[identity] ?? 0) + 1, 7) - failedAttempts[identity] = attempt - let delay = min(0.35 * pow(2, Double(attempt - 1)), 15) - retryAfter[identity] = now.addingTimeInterval(delay) - } - } - private func markHandled(_ identity: BrowserAutofill.WindowIdentity) { handledWindows[identity] = Date() - retryAfter.removeValue(forKey: identity) - failedAttempts.removeValue(forKey: identity) } private func setWorking(_ value: Bool) { @@ -452,12 +398,16 @@ final class BridgeModel: ObservableObject { private func refreshPermissions() { let newAccessibilityGranted = PermissionManager.accessibilityGranted let newScreenRecordingGranted = PermissionManager.screenRecordingGranted + let accessibilityChanged = accessibilityGranted != newAccessibilityGranted if accessibilityGranted != newAccessibilityGranted { accessibilityGranted = newAccessibilityGranted } if screenRecordingGranted != newScreenRecordingGranted { screenRecordingGranted = newScreenRecordingGranted } + if started, accessibilityChanged { + reconcileEventMonitoring() + } } private func refreshLaunchAtLogin() { @@ -498,6 +448,24 @@ final class BridgeModel: ObservableObject { denylistedApplications.sort { $0.displayName.localizedCompare($1.displayName) == .orderedAscending } saveApplications(denylistedApplications, key: Keys.denylistedApplications) } + reconcileEventMonitoring() + } + + private func requestEventDrivenScan() { + guard monitoringEnabled, + automaticFillEnabled, + accessibilityGranted else { + return + } + eventScanCoordinator.request() + } + + private func reconcileEventMonitoring() { + let enabled = started + && accessibilityGranted + && monitoringEnabled + && automaticFillEnabled + eventScanCoordinator.update(policy: applicationPolicy, enabled: enabled) } private func saveApplications(_ applications: [TargetApplication], key: String) { diff --git a/Sources/ApplePasswordBridge/EventScanCoordinator.swift b/Sources/ApplePasswordBridge/EventScanCoordinator.swift index 75086b6..b6b377b 100644 --- a/Sources/ApplePasswordBridge/EventScanCoordinator.swift +++ b/Sources/ApplePasswordBridge/EventScanCoordinator.swift @@ -39,10 +39,10 @@ final class EventScanScheduler { } guard !Task.isCancelled else { return } - pendingTask = nil actionRunning = true await action() actionRunning = false + pendingTask = nil if rerunRequested { rerunRequested = false @@ -62,6 +62,7 @@ final class EventScanScheduler { final class EventScanCoordinator { private let monitor: any AccessibilityEventMonitoring private let scheduler: EventScanScheduler + private var enabled = false private var stopped = false init( @@ -75,13 +76,14 @@ final class EventScanCoordinator { action: action ) self.scheduler = scheduler - monitor.onEvent = { [weak scheduler] in - scheduler?.request() + monitor.onEvent = { [weak self] in + self?.request() } } func update(policy: ApplicationRulePolicy, enabled: Bool) { guard !stopped else { return } + self.enabled = enabled let changed = monitor.update(policy: policy, enabled: enabled) if enabled, changed { scheduler.request() @@ -91,13 +93,14 @@ final class EventScanCoordinator { } func request() { - guard !stopped else { return } + guard !stopped, enabled else { return } scheduler.request() } func stop() { guard !stopped else { return } stopped = true + enabled = false monitor.onEvent = nil scheduler.cancel() monitor.stop() diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index f3c8ba3..d7ef6ae 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -37,100 +37,13 @@ final class BrowserDiagnosticsTests: XCTestCase { XCTAssertFalse(PermissionManager.needsScreenRecordingSettings(granted: true)) } - func test_scan_schedule_uses_idle_interval_without_active_deadline() { - let now = Date(timeIntervalSince1970: 100) - XCTAssertEqual(ScanSchedule.interval(now: now, activeUntil: nil, idleInterval: 15), 15) - } - - func test_scan_schedule_uses_fast_interval_until_deadline() { - let now = Date(timeIntervalSince1970: 100) - XCTAssertEqual( - ScanSchedule.interval(now: now, activeUntil: now.addingTimeInterval(0.1), idleInterval: 30), - 0.25 - ) - XCTAssertEqual(ScanSchedule.interval(now: now, activeUntil: now, idleInterval: 30), 30) - } - - func test_idle_scan_intervals_expose_exact_seconds() { - XCTAssertEqual(IdleScanInterval.allCases.map(\.seconds), [3, 5, 10, 15, 30]) - XCTAssertEqual(IdleScanInterval.allCases.map(\.title), ["3 秒", "5 秒", "10 秒", "15 秒", "30 秒"]) - } - - func test_idle_scan_interval_defaults_to_fifteen_seconds_and_round_trips_raw_value() { - XCTAssertEqual(IdleScanInterval.defaultValue, .seconds15) - XCTAssertEqual(IdleScanInterval(rawValue: 15), .seconds15) - } - - @MainActor - func test_bridge_model_reads_and_persists_idle_scan_interval() { - let key = "idleScanInterval" - let defaults = UserDefaults.standard - let originalValue = defaults.object(forKey: key) - defer { - if let originalValue { - defaults.set(originalValue, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - } - - defaults.set(30.0, forKey: key) - let model = BridgeModel(startAutomatically: false) - XCTAssertEqual(model.idleScanInterval, .seconds30) - - model.idleScanInterval = .seconds5 - XCTAssertEqual(defaults.double(forKey: key), 5) - } - - @MainActor - func test_new_candidate_window_activates_fast_scan_window() { - let candidate = BrowserAutofill.WindowIdentity(processIdentifier: 42, windowNumber: 7) - XCTAssertTrue(BridgeModel.hasNewCandidate([candidate], previous: [])) - XCTAssertFalse(BridgeModel.hasNewCandidate([candidate], previous: [candidate])) - } - - func test_scan_schedule_active_until_is_two_seconds_for_new_candidate() { - let now = Date(timeIntervalSince1970: 100) - XCTAssertEqual( - ScanSchedule.activeUntil(now: now, hasNewCandidate: true), - now.addingTimeInterval(2) - ) - XCTAssertNil(ScanSchedule.activeUntil(now: now, hasNewCandidate: false)) - } - - func test_accessibility_retry_schedule_keeps_full_warmup_for_manual_scan() { - XCTAssertEqual( - ScanSchedule.accessibilityRetryDelays(manual: true, hasNewCandidate: false), - [120, 160, 220, 300, 400] - ) - } - - func test_accessibility_retry_schedule_keeps_full_warmup_for_new_automatic_candidate() { + func test_event_and_manual_scans_use_full_accessibility_warmup() { XCTAssertEqual( - ScanSchedule.accessibilityRetryDelays(manual: false, hasNewCandidate: true), + AccessibilityRetrySchedule.delays, [120, 160, 220, 300, 400] ) } - func test_accessibility_retry_schedule_uses_one_attempt_for_stable_automatic_candidate() { - XCTAssertEqual( - ScanSchedule.accessibilityRetryDelays(manual: false, hasNewCandidate: false), - [120] - ) - } - - func test_scan_schedule_returns_to_idle_after_active_deadline() { - let now = Date(timeIntervalSince1970: 100) - XCTAssertEqual( - ScanSchedule.interval( - now: now, - activeUntil: now.addingTimeInterval(-0.001), - idleInterval: 15 - ), - 15 - ) - } - func test_accessibility_url_normalizer_accepts_string_url_nsurl_and_cfurl() { let raw = "chrome-extension://secret-id/page_popup.html?popupWindow=42#token" let foundationURL = URL(string: raw)! diff --git a/Tests/ApplePasswordBridgeTests/EventScanCoordinatorTests.swift b/Tests/ApplePasswordBridgeTests/EventScanCoordinatorTests.swift index c424d35..a9c2256 100644 --- a/Tests/ApplePasswordBridgeTests/EventScanCoordinatorTests.swift +++ b/Tests/ApplePasswordBridgeTests/EventScanCoordinatorTests.swift @@ -120,7 +120,7 @@ final class EventScanCoordinatorTests: XCTestCase { } func test_monitor_events_use_the_same_debounced_scheduler() async { - let monitor = FakeEventMonitor() + let monitor = FakeEventMonitor(updateResults: [false]) var calls = 0 let coordinator = EventScanCoordinator( monitor: monitor, @@ -129,6 +129,7 @@ final class EventScanCoordinatorTests: XCTestCase { calls += 1 } + coordinator.update(policy: makePolicy(), enabled: true) monitor.emit() monitor.emit() monitor.emit() @@ -156,4 +157,51 @@ final class EventScanCoordinatorTests: XCTestCase { XCTAssertEqual(calls, 0) XCTAssertEqual(monitor.stopCount, 1) } + + func test_disabled_coordinator_ignores_late_monitor_events() async { + let monitor = FakeEventMonitor(updateResults: [false, false]) + var calls = 0 + let coordinator = EventScanCoordinator( + monitor: monitor, + delayNanoseconds: 1_000_000 + ) { + calls += 1 + } + + coordinator.update(policy: makePolicy(), enabled: true) + coordinator.update(policy: makePolicy(), enabled: false) + monitor.emit() + try? await Task.sleep(nanoseconds: 30_000_000) + + XCTAssertEqual(calls, 0) + } + + func test_disabling_cancels_an_action_that_is_already_running() async { + let monitor = FakeEventMonitor(updateResults: [true, false]) + var actionStarted = false + var cancellationObserved = false + let coordinator = EventScanCoordinator( + monitor: monitor, + delayNanoseconds: 0 + ) { + actionStarted = true + while !Task.isCancelled { + await Task.yield() + } + cancellationObserved = true + } + + coordinator.update(policy: makePolicy(), enabled: true) + for _ in 0..<500 where !actionStarted { + await Task.yield() + } + XCTAssertTrue(actionStarted) + + coordinator.update(policy: makePolicy(), enabled: false) + for _ in 0..<500 where !cancellationObserved { + await Task.yield() + } + + XCTAssertTrue(cancellationObserved) + } } From c95fdf7ffb52149c00048d1f65766e8c37b5ea2c Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:45:22 +0800 Subject: [PATCH 39/39] fix: stop cancelled autofill work promptly --- Sources/ApplePasswordBridge/BridgeModel.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/ApplePasswordBridge/BridgeModel.swift b/Sources/ApplePasswordBridge/BridgeModel.swift index 126c4f8..d385b95 100644 --- a/Sources/ApplePasswordBridge/BridgeModel.swift +++ b/Sources/ApplePasswordBridge/BridgeModel.swift @@ -324,10 +324,13 @@ final class BridgeModel: ObservableObject { candidates: candidates, retryDelays: AccessibilityRetrySchedule.delays ) + } catch is CancellationError { + return } catch { if manual { statusText = error.localizedDescription } return } + guard !Task.isCancelled else { return } var captured = codeReader.readUsingAccessibility() if captured == nil, @@ -335,6 +338,7 @@ final class BridgeModel: ObservableObject { screenRecordingGranted { captured = await codeReader.readUsingVision() } + guard !Task.isCancelled else { return } if let captured { currentCode = captured @@ -353,6 +357,7 @@ final class BridgeModel: ObservableObject { } do { + guard !Task.isCancelled else { return } statusText = "已识别验证码,正在激活 \(target.application.localizedName ?? "目标应用")" try await browserAutofill.fill(code: code.value, target: target, speed: fillSpeed) lastSuccessfulCode = CapturedCode( @@ -363,6 +368,8 @@ final class BridgeModel: ObservableObject { currentCode = nil markHandled(target.retryIdentity) statusText = "已填入 \(target.application.localizedName ?? "目标应用")(\(code.source.rawValue))" + } catch is CancellationError { + return } catch { statusText = error.localizedDescription }