diff --git a/.gitignore b/.gitignore index 4c739aa..83dc698 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store .build/ +.worktrees/ dist/ *.xcuserstate diff --git a/Sources/ApplePasswordBridge/Accessibility.swift b/Sources/ApplePasswordBridge/Accessibility.swift index 494b362..5fe9601 100644 --- a/Sources/ApplePasswordBridge/Accessibility.swift +++ b/Sources/ApplePasswordBridge/Accessibility.swift @@ -1,11 +1,25 @@ 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 + } + + static func bool(from value: Any?) -> Bool? { + (value as? NSNumber)?.boolValue + } +} + 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 +72,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) )) @@ -80,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/AccessibilityEventMonitor.swift b/Sources/ApplePasswordBridge/AccessibilityEventMonitor.swift new file mode 100644 index 0000000..6788ab0 --- /dev/null +++ b/Sources/ApplePasswordBridge/AccessibilityEventMonitor.swift @@ -0,0 +1,348 @@ +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/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/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: "辅助功能", diff --git a/Sources/ApplePasswordBridge/BridgeModel.swift b/Sources/ApplePasswordBridge/BridgeModel.swift index 2da2e03..d385b95 100644 --- a/Sources/ApplePasswordBridge/BridgeModel.swift +++ b/Sources/ApplePasswordBridge/BridgeModel.swift @@ -3,13 +3,45 @@ import Combine import ServiceManagement import UniformTypeIdentifiers +struct AccessibilityRetrySchedule { + static let delays: [UInt64] = [120, 160, 220, 300, 400] +} + +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 { - 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) } @@ -18,7 +50,10 @@ final class BridgeModel: ObservableObject { didSet { UserDefaults.standard.set(fillSpeed.rawValue, forKey: Keys.fillSpeed) } } @Published var applicationRuleMode: ApplicationRuleMode { - didSet { UserDefaults.standard.set(applicationRuleMode.rawValue, forKey: Keys.applicationRuleMode) } + didSet { + UserDefaults.standard.set(applicationRuleMode.rawValue, forKey: Keys.applicationRuleMode) + if started { reconcileEventMonitoring() } + } } @Published private(set) var allowlistedApplications: [TargetApplication] @Published private(set) var denylistedApplications: [TargetApplication] @@ -27,6 +62,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,18 +77,28 @@ final class BridgeModel: ObservableObject { private let codeReader = PasswordCodeReader() private let browserAutofill = BrowserAutofill() - private var scanTimer: Timer? + private let browserDiagnostics: any BrowserDiagnosticsRunning + 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 - init() { + 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, @@ -76,11 +123,54 @@ final class BridgeModel: ObservableObject { key: Keys.denylistedApplications, fallback: [] ) - Task { @MainActor [weak self] in - self?.start() + 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 + defer { isDiagnosing = false } + let policy = applicationPolicy + let applications = activeApplicationRules + let now = Date() + let runner = browserDiagnostics + 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 + } + + 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 @@ -102,15 +192,7 @@ final class BridgeModel: ObservableObject { let hotKeyReady = hotKey?.start() == true statusText = hotKeyReady ? "等待 Apple 密码授权窗口" : "快捷键注册失败" - scanTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - guard let self else { return } - if self.monitoringEnabled && self.automaticFillEnabled { - await self.scanAndFill(manual: false) - } - } - } - scanTimer?.tolerance = 0.15 + reconcileEventMonitoring() } func fillNow() async { @@ -123,11 +205,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() { @@ -185,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 { @@ -203,12 +293,9 @@ final class BridgeModel: ObservableObject { 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 } @@ -225,17 +312,25 @@ final class BridgeModel: ObservableObject { Task { @MainActor [weak self] in await self?.scanAndFill(manual: true) } + } else if automaticRequestPending { + automaticRequestPending = false + requestEventDrivenScan() } } let target: BrowserAutofill.Target do { - target = try await locateTargetAfterAccessibilityWarmup(candidates: candidates) + target = try await locateTargetAfterAccessibilityWarmup( + candidates: candidates, + retryDelays: AccessibilityRetrySchedule.delays + ) + } catch is CancellationError { + return } catch { - scheduleRetry(for: candidates.map(\.identity)) if manual { statusText = error.localizedDescription } return } + guard !Task.isCancelled else { return } var captured = codeReader.readUsingAccessibility() if captured == nil, @@ -243,13 +338,13 @@ final class BridgeModel: ObservableObject { screenRecordingGranted { captured = await codeReader.readUsingVision() } + guard !Task.isCancelled else { return } if let captured { currentCode = captured } guard let code = currentCode, code.isFresh() else { currentCode = nil - scheduleRetry(for: [target.identity]) if manual { statusText = "未发现有效的 Apple 密码验证码窗口" } return } @@ -257,11 +352,12 @@ final class BridgeModel: ObservableObject { $0.value == code.value && $0.isFresh(at: now) } ?? false guard manual || !isRecentlyFilledCode else { - markHandled(target.identity) + markHandled(target.retryIdentity) return } do { + guard !Task.isCancelled else { return } statusText = "已识别验证码,正在激活 \(target.application.localizedName ?? "目标应用")" try await browserAutofill.fill(code: code.value, target: target, speed: fillSpeed) lastSuccessfulCode = CapturedCode( @@ -270,20 +366,21 @@ final class BridgeModel: ObservableObject { source: code.source ) currentCode = nil - markHandled(target.identity) + markHandled(target.retryIdentity) statusText = "已填入 \(target.application.localizedName ?? "目标应用")(\(code.source.rawValue))" + } catch is CancellationError { + return } catch { - scheduleRetry(for: [target.identity]) statusText = error.localizedDescription } } 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) @@ -296,20 +393,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) { @@ -320,12 +405,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() { @@ -366,6 +455,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/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift new file mode 100644 index 0000000..41004f5 --- /dev/null +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -0,0 +1,267 @@ +import Foundation +import AppKit +import CoreGraphics + +public enum BrowserDiagnosticConclusion: String, Equatable, Sendable { + 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 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 { + 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 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)) else { return nil } + return extensionSummary(text, match: m) + } + public static func redactTitle(_ title: String?) -> String { String(redactText(title ?? "").prefix(160)) } +} + +public struct DiagnosticWindow: Equatable, Sendable { + 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 = DiagnosticRedactor.redactTitle(title) + self.titleMatches = titleMatches + } +} + +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 hasAccessibilityExtensionURL: Bool + public let hasAccessibilityPopupURL: 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, 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 + 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, Sendable { + 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 + } +} + +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() {} + 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.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 } + guard !matching.isEmpty else { return .authorizationContextMismatch } + guard matching.contains(where: { $0.supportedInputCount > 0 }) else { return .inputRolesUnrecognized } + return .targetRecognized + } + + 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 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, 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) + } + + 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 { + if Task.isCancelled { return BrowserDiagnosticReport(generatedAt: now, ruleMode: policy.mode, applications: []) } + let pairs: [(TargetApplication, NSRunningApplication?)] + switch policy.mode { + 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 ?? "") } + 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, 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) } } ?? [] + var axWindows: [DiagnosticAccessibilityWindow] = [] + 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, 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" } } + 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, Sendable { + 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 } + private func renderPlainText() -> String { + var lines = ["generatedAt=\(generatedAt.timeIntervalSince1970)", "ruleMode=\(ruleMode.rawValue)", "conclusion=\(conclusion.rawValue)", "summary=\(summary)"] + 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.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)") } + 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.hasAccessibilityExtensionURL), bit(ax.hasAccessibilityPopupURL), 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") 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") + } + public var plainText: String { renderPlainText() } +} + +protocol BrowserDiagnosticsRunning: Sendable { func run(policy: ApplicationRulePolicy, configuredApplications: [TargetApplication], now: Date) -> BrowserDiagnosticReport } diff --git a/Sources/ApplePasswordBridge/CodeParser.swift b/Sources/ApplePasswordBridge/CodeParser.swift index cc87ca8..ef85252 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 { @@ -38,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/EventScanCoordinator.swift b/Sources/ApplePasswordBridge/EventScanCoordinator.swift new file mode 100644 index 0000000..b6b377b --- /dev/null +++ b/Sources/ApplePasswordBridge/EventScanCoordinator.swift @@ -0,0 +1,108 @@ +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 } + + actionRunning = true + await action() + actionRunning = false + pendingTask = nil + + 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 enabled = false + 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 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() + } else if !enabled { + scheduler.cancel() + } + } + + func request() { + 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/Sources/ApplePasswordBridge/FirefoxAutofill.swift b/Sources/ApplePasswordBridge/FirefoxAutofill.swift index 96a60ad..789caf7 100644 --- a/Sources/ApplePasswordBridge/FirefoxAutofill.swift +++ b/Sources/ApplePasswordBridge/FirefoxAutofill.swift @@ -24,25 +24,46 @@ 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] } func authorizationWindowCandidates(policy: ApplicationRulePolicy) -> [Candidate] { - let applications = eligibleApplicationsByPID(policy: policy) + let applications = Self.eligibleApplicationsByPID(policy: policy) guard !applications.isEmpty else { return [] } let windowInfo = CGWindowListCopyWindowInfo( @@ -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 @@ -151,7 +182,7 @@ final class BrowserAutofill { } } - private func eligibleApplicationsByPID( + static func eligibleApplicationsByPID( policy: ApplicationRulePolicy ) -> [pid_t: NSRunningApplication] { var unique: [String: NSRunningApplication] = [:] 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/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]) + } +} diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift new file mode 100644 index 0000000..d7ef6ae --- /dev/null +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -0,0 +1,438 @@ +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() + } +} + +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 { + func test_screen_recording_settings_are_needed_when_request_is_not_granted() { + XCTAssertTrue(PermissionManager.needsScreenRecordingSettings(granted: false)) + XCTAssertFalse(PermissionManager.needsScreenRecordingSettings(granted: true)) + } + + func test_event_and_manual_scans_use_full_accessibility_warmup() { + XCTAssertEqual( + AccessibilityRetrySchedule.delays, + [120, 160, 220, 300, 400] + ) + } + + 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))) + } + + 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: []) + 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) + } + + @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) + } + + 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) + + 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() { + 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_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( + 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) + } + 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.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) + } + + 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 = 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("axURL=true")) + 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() { + 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") + 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) + 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_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")) + } + + 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")) + } + + 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/Tests/ApplePasswordBridgeTests/EventScanCoordinatorTests.swift b/Tests/ApplePasswordBridgeTests/EventScanCoordinatorTests.swift new file mode 100644 index 0000000..a9c2256 --- /dev/null +++ b/Tests/ApplePasswordBridgeTests/EventScanCoordinatorTests.swift @@ -0,0 +1,207 @@ +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(updateResults: [false]) + var calls = 0 + let coordinator = EventScanCoordinator( + monitor: monitor, + delayNanoseconds: 1_000_000 + ) { + calls += 1 + } + + coordinator.update(policy: makePolicy(), enabled: true) + 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) + } + + 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) + } +} 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. 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. 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..5ac7aa4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-arc-origin-gated-autofill.md @@ -0,0 +1,345 @@ +# 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 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. + +--- + +### 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` + +**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: + +```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 { + 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" +} +``` + +- [ ] **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: add origin-gated candidate helpers" +``` + +### Task 2: Atomically wire and 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. 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** + +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` 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** + +Before the CG title mismatch guard, compute trusted AX windows: + +```swift +let trusted = permitted.flatMap(\.accessibilityWindows).filter { + $0.hasAccessibilityPopupURL + && $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. 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. 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" +``` 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..a8bc54e --- /dev/null +++ b/docs/superpowers/plans/2026-09-09-candidate-aware-accessibility-retry.md @@ -0,0 +1,49 @@ +# Candidate-Aware Accessibility Retry Implementation Plan + +**Goal:** Reduce stable Arc idle scanning from five accessibility tree traversals to one while preserving full warm-up for new candidates and manual fills. + +**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. + +**Tech Stack:** Swift 5.10, AppKit, XCTest, SwiftPM. + +--- + +### Task 1: Add the pure retry policy with TDD + +**Files:** +- Modify: `Sources/ApplePasswordBridge/BridgeModel.swift` +- Modify: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] 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. + +### Task 2: Integrate candidate novelty into target lookup + +**Files:** +- Modify: `Sources/ApplePasswordBridge/BridgeModel.swift` + +- [ ] 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. + +### Task 3: Verify and package + +**Files:** +- No source changes expected. + +- [ ] 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. + +## Success Criteria + +- 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. 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. 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或输入框兼容逻辑。 +- 不新增磁盘日志、遥测或网络上传。 +- 不自动收集或提交诊断报告。 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..610e1df --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-arc-origin-gated-autofill-design.md @@ -0,0 +1,67 @@ +# 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. + +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, 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. + +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 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 + +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. 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..bd8340a --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-arc-untitled-popup-fallback-design.md @@ -0,0 +1,60 @@ +# Arc Authorization Origin Diagnostics Design + +## Decision + +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 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. + +## Evidence and Risk + +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`. + +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 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. + +## Diagnostic Changes + +Extend the read-only Accessibility observation model to capture these signals without changing focus, activation, keyboard events, or clipboard behavior: + +- 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. + +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. + +## Data Flow and Isolation + +`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. + +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 + +Add failing tests before implementation for: + +- `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. + +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 + +- 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. 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. 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`. 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. 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.