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/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..6ae87ec 100644 --- a/Sources/ApplePasswordBridge/BridgeModel.swift +++ b/Sources/ApplePasswordBridge/BridgeModel.swift @@ -3,6 +3,28 @@ import Combine import ServiceManagement import UniformTypeIdentifiers +private final class DiagnosticsTaskBox: @unchecked Sendable { + private let lock = NSLock() + private var task: Task? + private var cancellationRequested = false + + func set(_ task: Task) { + lock.lock() + self.task = task + let shouldCancel = cancellationRequested + lock.unlock() + if shouldCancel { task.cancel() } + } + + func cancel() { + lock.lock() + cancellationRequested = true + let task = task + lock.unlock() + task?.cancel() + } +} + @MainActor final class BridgeModel: ObservableObject { @Published var monitoringEnabled: Bool { @@ -27,6 +49,8 @@ final class BridgeModel: ObservableObject { @Published private(set) var launchAtLogin = false @Published private(set) var statusText = "正在启动" @Published private(set) var isWorking = false + @Published private(set) var diagnosticReport: BrowserDiagnosticReport? + @Published private(set) var isDiagnosing = false private enum Keys { static let monitoring = "monitoringEnabled" @@ -40,6 +64,7 @@ final class BridgeModel: ObservableObject { private let codeReader = PasswordCodeReader() private let browserAutofill = BrowserAutofill() + private let browserDiagnostics: any BrowserDiagnosticsRunning private var scanTimer: Timer? private var hotKey: GlobalHotKey? private var currentCode: CapturedCode? @@ -51,7 +76,8 @@ final class BridgeModel: ObservableObject { private var manualRequestPending = false private var started = false - init() { + init(browserDiagnostics: any BrowserDiagnosticsRunning = BrowserDiagnostics(), startAutomatically: Bool = true) { + self.browserDiagnostics = browserDiagnostics UserDefaults.standard.register(defaults: [ Keys.monitoring: true, Keys.automaticFill: true, @@ -76,11 +102,39 @@ final class BridgeModel: ObservableObject { key: Keys.denylistedApplications, fallback: [] ) - Task { @MainActor [weak self] in - self?.start() + if startAutomatically { + Task { @MainActor [weak self] in self?.start() } } } + func runDiagnostics() async { + guard !isDiagnosing else { return } + isDiagnosing = true + defer { isDiagnosing = false } + let policy = applicationPolicy + let applications = activeApplicationRules + let now = Date() + let runner = browserDiagnostics + let 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 @@ -249,7 +303,7 @@ final class BridgeModel: ObservableObject { } guard let code = currentCode, code.isFresh() else { currentCode = nil - scheduleRetry(for: [target.identity]) + scheduleRetry(for: [target.retryIdentity]) if manual { statusText = "未发现有效的 Apple 密码验证码窗口" } return } @@ -257,7 +311,7 @@ final class BridgeModel: ObservableObject { $0.value == code.value && $0.isFresh(at: now) } ?? false guard manual || !isRecentlyFilledCode else { - markHandled(target.identity) + markHandled(target.retryIdentity) return } @@ -270,10 +324,10 @@ final class BridgeModel: ObservableObject { source: code.source ) currentCode = nil - markHandled(target.identity) + markHandled(target.retryIdentity) statusText = "已填入 \(target.application.localizedName ?? "目标应用")(\(code.source.rawValue))" } catch { - scheduleRetry(for: [target.identity]) + scheduleRetry(for: [target.retryIdentity]) statusText = error.localizedDescription } } diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift 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/FirefoxAutofill.swift b/Sources/ApplePasswordBridge/FirefoxAutofill.swift index 96a60ad..4042551 100644 --- a/Sources/ApplePasswordBridge/FirefoxAutofill.swift +++ b/Sources/ApplePasswordBridge/FirefoxAutofill.swift @@ -24,18 +24,39 @@ enum AutofillFailure: LocalizedError { } final class BrowserAutofill { + static func acceptsTarget( + requiresTrustedOrigin: Bool, + diagnosticURLs: [String], + hasAuthorizationContext: Bool, + hasStablePopupSignature: Bool, + inputCount: Int + ) -> Bool { + if requiresTrustedOrigin { + return diagnosticURLs.contains(where: AuthorizationContext.isBrowserExtensionPopupURL) + && hasAuthorizationContext + && inputCount >= 6 + } + return (hasStablePopupSignature || hasAuthorizationContext) && inputCount > 0 + } + struct WindowIdentity: Hashable { let processIdentifier: pid_t let windowNumber: CGWindowID } + struct CandidateDescriptor: Equatable { + let identity: WindowIdentity + let requiresTrustedOrigin: Bool + } + struct Candidate { let identity: WindowIdentity let application: NSRunningApplication + let requiresTrustedOrigin: Bool } struct Target { - let identity: WindowIdentity + let retryIdentity: WindowIdentity let application: NSRunningApplication let window: AXUIElement let fields: [AccessibilityNode] @@ -50,28 +71,30 @@ final class BrowserAutofill { kCGNullWindowID ) as? [[String: Any]] ?? [] - var candidates: [Candidate] = [] - var seen = Set() - for info in windowInfo { - let title = info[kCGWindowName as String] as? String - guard AuthorizationContext.isICloudPasswordWindowTitle(title), - let rawPID = info[kCGWindowOwnerPID as String] as? NSNumber, - let rawWindowNumber = info[kCGWindowNumber as String] as? NSNumber else { - continue + return Self.selectCandidates(windowInfo: windowInfo, eligiblePIDs: Set(applications.keys)) + .compactMap { descriptor in + guard let application = applications[descriptor.identity.processIdentifier] else { return nil } + return Candidate(identity: descriptor.identity, application: application, requiresTrustedOrigin: descriptor.requiresTrustedOrigin) } - let identity = WindowIdentity( - processIdentifier: rawPID.int32Value, - windowNumber: CGWindowID(rawWindowNumber.uint32Value) - ) - guard !seen.contains(identity), - let application = applications[identity.processIdentifier] else { - continue + } + + static func selectCandidates(windowInfo: [[String: Any]], eligiblePIDs: Set) -> [CandidateDescriptor] { + struct Entry { let descriptor: CandidateDescriptor; let index: Int } + var selected: [pid_t: Entry] = [:] + for (index, info) in windowInfo.enumerated() { + guard let rawPID = info[kCGWindowOwnerPID as String] as? NSNumber, + let rawWindowNumber = info[kCGWindowNumber as String] as? NSNumber else { continue } + let pid = rawPID.int32Value + guard eligiblePIDs.contains(pid) else { continue } + let identity = WindowIdentity(processIdentifier: pid, windowNumber: CGWindowID(rawWindowNumber.uint32Value)) + let trusted = AuthorizationContext.isICloudPasswordWindowTitle(info[kCGWindowName as String] as? String) + if let existing = selected[pid] { + if trusted && existing.descriptor.requiresTrustedOrigin { selected[pid] = Entry(descriptor: CandidateDescriptor(identity: identity, requiresTrustedOrigin: false), index: index) } + } else { + selected[pid] = Entry(descriptor: CandidateDescriptor(identity: identity, requiresTrustedOrigin: !trusted), index: index) } - seen.insert(identity) - candidates.append(Candidate(identity: identity, application: application)) - if candidates.count == 4 { break } } - return candidates + return selected.values.sorted { $0.index < $1.index }.map(\.descriptor) } func prepareAccessibility(for candidates: [Candidate]) { @@ -112,10 +135,18 @@ final class BrowserAutofill { title: title, text: text ) - guard hasStablePopupSignature || hasAuthorizationContext else { continue } + let diagnosticURLs = nodes.compactMap(\.diagnosticURL) + guard Self.acceptsTarget( + requiresTrustedOrigin: applicationCandidates[0].requiresTrustedOrigin, + diagnosticURLs: diagnosticURLs, + hasAuthorizationContext: hasAuthorizationContext, + hasStablePopupSignature: hasStablePopupSignature, + inputCount: fields.count + ) else { continue } guard !fields.isEmpty else { throw AutofillFailure.inputNotFound } + // The retry key comes from CG scheduling; `window` is this verified AX window. return Target( - identity: applicationCandidates[0].identity, + retryIdentity: applicationCandidates[0].identity, application: application, window: window, fields: fields diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift new file mode 100644 index 0000000..525de90 --- /dev/null +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -0,0 +1,426 @@ +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_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/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/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.