Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1be0561
docs: design Arc authorization diagnostics
hellosky0 Aug 3, 2026
99d6f97
docs: plan Arc authorization diagnostics
hellosky0 Aug 3, 2026
65dc16d
chore: ignore local worktrees
hellosky0 Aug 3, 2026
d65a65c
feat: model browser diagnostic outcomes
hellosky0 Aug 3, 2026
6a3d96f
fix: align diagnostic outcome evaluation
hellosky0 Aug 3, 2026
a7aebe5
test: cover diagnostic outcome precedence
hellosky0 Aug 3, 2026
3a5392f
feat: redact and render diagnostic reports
hellosky0 Aug 3, 2026
33f0b68
fix: redact diagnostic observations at capture
hellosky0 Aug 3, 2026
f62bd5d
fix: preserve diagnostic URL paths
hellosky0 Aug 3, 2026
ca98600
feat: collect browser recognition diagnostics
hellosky0 Aug 3, 2026
479172d
fix: keep browser diagnostics read only
hellosky0 Aug 3, 2026
49def5e
feat: expose one-shot browser diagnostics
hellosky0 Aug 3, 2026
775e6eb
fix: make diagnostic tasks cancellation aware
hellosky0 Aug 3, 2026
3302567
feat: show browser diagnostics in menu
hellosky0 Aug 3, 2026
7465a98
docs: design Arc popup fallback
hellosky0 Aug 4, 2026
b86f16c
docs: revise Arc fallback security design
hellosky0 Aug 4, 2026
972d8bb
docs: plan Arc origin diagnostics
hellosky0 Aug 4, 2026
666785e
feat: observe accessibility URLs for diagnostics
hellosky0 Aug 4, 2026
0bd5919
feat: report accessibility window structure
hellosky0 Aug 4, 2026
78b511d
docs: design origin-gated Arc autofill
hellosky0 Aug 4, 2026
c8f41bc
docs: plan origin-gated Arc autofill
hellosky0 Aug 4, 2026
ecfa4d4
feat: collect Arc authorization origin evidence
hellosky0 Aug 4, 2026
d5f387b
feat: gate untitled popup autofill on extension origin
hellosky0 Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.DS_Store
.build/
.worktrees/
dist/
*.xcuserstate
30 changes: 30 additions & 0 deletions Sources/ApplePasswordBridge/Accessibility.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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)
))

Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions Sources/ApplePasswordBridge/ApplicationRules.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ enum FillSpeed: String, CaseIterable, Identifiable {
}
}

enum ApplicationRuleMode: String, CaseIterable, Identifiable {
enum ApplicationRuleMode: String, CaseIterable, Identifiable, Sendable {
case allowlist
case denylist

Expand All @@ -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

Expand All @@ -50,7 +50,7 @@ struct TargetApplication: Codable, Hashable, Identifiable {
)
}

struct ApplicationRulePolicy {
struct ApplicationRulePolicy: Sendable {
let mode: ApplicationRuleMode
let allowlist: Set<String>
let denylist: Set<String>
Expand Down
34 changes: 34 additions & 0 deletions Sources/ApplePasswordBridge/BridgeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: "辅助功能",
Expand Down
68 changes: 61 additions & 7 deletions Sources/ApplePasswordBridge/BridgeModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,28 @@ import Combine
import ServiceManagement
import UniformTypeIdentifiers

private final class DiagnosticsTaskBox: @unchecked Sendable {
private let lock = NSLock()
private var task: Task<BrowserDiagnosticReport, Never>?
private var cancellationRequested = false

func set(_ task: Task<BrowserDiagnosticReport, Never>) {
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 {
Expand All @@ -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"
Expand All @@ -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?
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -249,15 +303,15 @@ 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
}
let isRecentlyFilledCode = lastSuccessfulCode.map {
$0.value == code.value && $0.isFresh(at: now)
} ?? false
guard manual || !isRecentlyFilledCode else {
markHandled(target.identity)
markHandled(target.retryIdentity)
return
}

Expand All @@ -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
}
}
Expand Down
Loading