Real grammar engine + scan every text area on screen

- SystemGrammarEngine: on-device spelling + grammar via NSSpellChecker (no deps,
  no network), augmented with deterministic Harper-style rules. Now the default.
- TextFieldScanner: walks the frontmost window's Accessibility tree to find every
  editable text area (bounded for huge web/Electron trees), not just the focused one.
- LiveMonitor now publishes per-field issues across the whole window + fix methods
  (apply one suggestion, fix a field, fix everything) via AX value writes.
- LiveCheckView: a panel listing each text area with errors highlighted inline and
  one-click fixes; opened from the menu bar (Show live issues / Show issues / Fix all).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BigMarc 2026-06-17 15:36:24 +02:00
parent e952eb441c
commit 5d1f152fc7
6 changed files with 510 additions and 44 deletions

View File

@ -39,11 +39,12 @@ final class AppState: ObservableObject {
private var onboardingWindow: NSWindow? private var onboardingWindow: NSWindow?
private var askWindow: NSWindow? private var askWindow: NSWindow?
private var mainWindow: NSWindow? private var mainWindow: NSWindow?
private var liveCheckWindow: NSWindow?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private init() { private init() {
let harper = HarperEngine() let engine = SystemGrammarEngine()
grammar = harper grammar = engine
ai = MLXEngine(ready: true) ai = MLXEngine(ready: true)
permissions = Permissions() permissions = Permissions()
model = ModelManager() model = ModelManager()
@ -59,9 +60,9 @@ final class AppState: ObservableObject {
snippets = SnippetStore() snippets = SnippetStore()
let excl = Exclusions() let excl = Exclusions()
exclusions = excl exclusions = excl
liveMonitor = LiveMonitor(grammar: harper, detector: detector, exclusions: excl, capture: capture) liveMonitor = LiveMonitor(grammar: engine, detector: detector, exclusions: excl, capture: capture)
coordinator = TextReplacementCoordinator( coordinator = TextReplacementCoordinator(
capture: capture, grammar: harper, ai: ai, gate: gate, detector: detector) capture: capture, grammar: engine, ai: ai, gate: gate, detector: detector)
} }
/// Called once at launch (from the AppDelegate). /// Called once at launch (from the AppDelegate).
@ -140,6 +141,27 @@ final class AppState: ObservableObject {
liveMonitor.refreshSoon() liveMonitor.refreshSoon()
} }
// MARK: - Live multi-field fixes (from the Live Check panel)
func fixAllDetected() {
if let reason = blockReason() { lastStatus = reason; return }
let n = liveMonitor.fixAll()
if n > 0 { usage.recordCorrection(words: n) }
lastStatus = n > 0 ? "Fixed \(n) issue\(n == 1 ? "" : "s")" : "Nothing to fix"
}
func fixDetectedField(_ field: DetectedField) {
if let reason = blockReason() { lastStatus = reason; return }
let n = liveMonitor.fix(field)
if n > 0 { usage.recordCorrection(words: n) }
}
func applySuggestion(_ suggestion: Suggestion, in field: DetectedField) {
if let reason = blockReason() { lastStatus = reason; return }
liveMonitor.apply(suggestion, in: field)
usage.recordCorrection(words: 1)
}
/// Pause/resume all of GrammaGem (hotkeys + live monitoring). /// Pause/resume all of GrammaGem (hotkeys + live monitoring).
func togglePause() { func togglePause() {
isPaused.toggle() isPaused.toggle()
@ -258,6 +280,27 @@ final class AppState: ObservableObject {
mainWindow?.makeKeyAndOrderFront(nil) mainWindow?.makeKeyAndOrderFront(nil)
} }
/// The Live Check panel: every on-screen text area with grammar issues.
func showLiveCheck() {
if liveCheckWindow == nil {
let root = LiveCheckView()
.environmentObject(self)
.environmentObject(liveMonitor)
let host = NSHostingController(rootView: root)
let win = NSWindow(contentViewController: host)
win.title = "Live Check"
win.styleMask = [.titled, .closable, .miniaturizable, .resizable]
win.setContentSize(NSSize(width: 560, height: 620))
win.minSize = NSSize(width: 460, height: 420)
win.isReleasedWhenClosed = false
win.center()
liveCheckWindow = win
}
liveMonitor.refreshSoon()
NSApp.activate(ignoringOtherApps: true)
liveCheckWindow?.makeKeyAndOrderFront(nil)
}
func showAsk() { func showAsk() {
askText = "" askText = ""
if askWindow == nil { if askWindow == nil {

View File

@ -0,0 +1,169 @@
import SwiftUI
import AppKit
/// A floating panel that lists every on-screen text area with grammar/spelling
/// issues, shows the offending text with the errors highlighted, and offers
/// one-click fixes (per issue, per field, or everything).
struct LiveCheckView: View {
@EnvironmentObject private var app: AppState
@ObservedObject private var monitor: LiveMonitor
init() {
_monitor = ObservedObject(wrappedValue: AppState.shared.liveMonitor)
}
var body: some View {
VStack(spacing: 0) {
header
Divider()
if monitor.fields.isEmpty {
emptyState
} else {
ScrollView {
VStack(spacing: 14) {
ForEach(monitor.fields) { field in
FieldCard(field: field)
}
}
.padding(16)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.tint(GG.emerald)
}
private var header: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(summary).font(.headline)
Text(monitor.activeAppName.isEmpty
? "Checking text areas on screen"
: "In \(monitor.activeAppName) · \(monitor.scannedCount) text area\(monitor.scannedCount == 1 ? "" : "s") checked")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button { monitor.refreshSoon() } label: { Image(systemName: "arrow.clockwise") }
.help("Re-scan now")
if monitor.issueCount > 0 {
Button { app.fixAllDetected() } label: {
Label("Fix everything", systemImage: "checkmark.circle.fill")
}
.buttonStyle(.borderedProminent)
}
}
.padding(16)
}
private var summary: String {
let issues = monitor.issueCount
if issues == 0 { return "No issues found" }
return "\(issues) issue\(issues == 1 ? "" : "s") across \(monitor.fields.count) text area\(monitor.fields.count == 1 ? "" : "s")"
}
private var emptyState: some View {
VStack(spacing: 10) {
Image(systemName: "checkmark.seal.fill").font(.system(size: 42)).foregroundStyle(GG.emerald)
Text("Looks clean").font(.title3.weight(.semibold))
Text("No grammar or spelling issues in the text on screen right now.")
.font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(40)
}
}
/// One detected text area: highlighted text + its individual issues.
private struct FieldCard: View {
@EnvironmentObject private var app: AppState
let field: DetectedField
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Image(systemName: field.isFocused ? "cursorarrow.rays" : "text.cursor")
.foregroundStyle(field.isFocused ? GG.emerald : .secondary)
Text(field.label).font(.subheadline.weight(.semibold)).lineLimit(1)
Text(field.roleName).font(.caption).foregroundStyle(.secondary)
Spacer()
Text("\(field.suggestions.count)")
.font(.caption.weight(.semibold))
.padding(.horizontal, 7).padding(.vertical, 2)
.background(GG.gold.opacity(0.2), in: Capsule())
.foregroundStyle(GG.gold)
}
Text(highlighted(field.text, field.suggestions))
.font(.callout)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
VStack(spacing: 6) {
ForEach(field.suggestions) { s in
HStack(spacing: 8) {
Circle().fill(color(for: s.kind)).frame(width: 7, height: 7)
VStack(alignment: .leading, spacing: 1) {
Text(s.message).font(.caption)
if s.replacement != s.original && !s.replacement.isEmpty {
Text("“\(s.original)” → “\(s.replacement)”")
.font(.caption2.monospaced()).foregroundStyle(.secondary).lineLimit(1)
}
}
Spacer()
if s.replacement != s.original && !s.replacement.isEmpty {
Button("Apply") { app.applySuggestion(s, in: field) }
.controlSize(.small)
}
}
}
}
HStack {
Spacer()
Button { app.fixDetectedField(field) } label: {
Label("Fix this field", systemImage: "checkmark.circle")
}
.controlSize(.small)
}
}
.padding(14)
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.primary.opacity(0.07)))
}
private func color(for kind: GrammarKind) -> Color {
switch kind {
case .spelling: return .red
case .grammar, .punctuation: return .orange
default: return GG.gold
}
}
/// Build an attributed string with each issue span tinted.
private func highlighted(_ text: String, _ suggestions: [Suggestion]) -> AttributedString {
let ns = text as NSString
let sorted = suggestions.sorted { $0.location < $1.location }
var result = AttributedString()
var cursor = 0
for s in sorted {
let loc = s.location, len = s.length
guard loc >= cursor, len > 0, loc + len <= ns.length else { continue }
if loc > cursor {
result += AttributedString(ns.substring(with: NSRange(location: cursor, length: loc - cursor)))
}
var run = AttributedString(ns.substring(with: NSRange(location: loc, length: len)))
let tint: Color = s.kind == .spelling ? .red : .orange
run.backgroundColor = tint.opacity(0.18)
run.foregroundColor = tint
run.underlineStyle = .single
result += run
cursor = loc + len
}
if cursor < ns.length {
result += AttributedString(ns.substring(with: NSRange(location: cursor, length: ns.length - cursor)))
}
return result
}
}

View File

@ -57,6 +57,7 @@ struct MenuBarContent: View {
Divider() Divider()
actionRow(title: "Show live issues…", shortcut: nil) { app.showLiveCheck() }
actionRow(title: "Open GrammaGem…", shortcut: nil) { app.showMainWindow() } actionRow(title: "Open GrammaGem…", shortcut: nil) { app.showMainWindow() }
actionRow(title: "Manage devices…", shortcut: nil) { app.showMainWindow(select: .devices) } actionRow(title: "Manage devices…", shortcut: nil) { app.showMainWindow(select: .devices) }
actionRow(title: "Page blocker…", shortcut: nil) { app.showMainWindow(select: .exclusions) } actionRow(title: "Page blocker…", shortcut: nil) { app.showMainWindow(select: .exclusions) }
@ -102,24 +103,31 @@ struct MenuBarContent: View {
@ViewBuilder @ViewBuilder
private var liveStatus: some View { private var liveStatus: some View {
let inApp = monitor.activeAppName.isEmpty ? "" : " in \(monitor.activeAppName)"
if app.isPaused { if app.isPaused {
Label("Paused — not monitoring", systemImage: "pause.circle") Label("Paused — not monitoring", systemImage: "pause.circle")
.font(.callout).foregroundStyle(.secondary) .font(.callout).foregroundStyle(.secondary)
} else if monitor.issueCount > 0 { } else if monitor.issueCount > 0 {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Label("\(monitor.issueCount) suggestion\(monitor.issueCount == 1 ? "" : "s")" Label("\(monitor.issueCount) issue\(monitor.issueCount == 1 ? "" : "s") in "
+ (monitor.activeAppName.isEmpty ? "" : " in \(monitor.activeAppName)"), + "\(monitor.fields.count) text area\(monitor.fields.count == 1 ? "" : "s")" + inApp,
systemImage: "text.badge.checkmark") systemImage: "text.badge.xmark")
.font(.callout).foregroundStyle(.primary) .font(.callout).foregroundStyle(.primary)
ForEach(monitor.suggestions.prefix(3)) { s in ForEach(monitor.fields.prefix(2)) { f in
Text("• \(s.message): “\(s.original)” → “\(s.replacement)”") Text("• \(f.label): \(f.suggestions.count) issue\(f.suggestions.count == 1 ? "" : "s")")
.font(.caption).foregroundStyle(.secondary).lineLimit(1) .font(.caption).foregroundStyle(.secondary).lineLimit(1)
} }
Button { Task { await app.fixFocusedField() } } label: { HStack {
Button { app.showLiveCheck() } label: {
Label("Show issues", systemImage: "list.bullet.rectangle")
}
.controlSize(.small)
Button { app.fixAllDetected() } label: {
Label("Fix all", systemImage: "checkmark.circle.fill") Label("Fix all", systemImage: "checkmark.circle.fill")
} }
.buttonStyle(.borderedProminent).controlSize(.small) .buttonStyle(.borderedProminent).controlSize(.small)
} }
}
} else if monitor.running { } else if monitor.running {
Label(monitor.activeAppName.isEmpty ? "Monitoring — looks clean" Label(monitor.activeAppName.isEmpty ? "Monitoring — looks clean"
: "Watching \(monitor.activeAppName) — looks clean", : "Watching \(monitor.activeAppName) — looks clean",

View File

@ -0,0 +1,93 @@
import Foundation
import AppKit
/// Real, on-device grammar + spelling using macOS's built-in `NSSpellChecker`
/// (the same engine TextEdit/Mail use). No network, no model — fully local,
/// matching the privacy promise. Augmented with a few deterministic Harper-style
/// rules (repeated words, lowercase "i", common expansions) the system checker
/// doesn't surface.
final class SystemGrammarEngine: GrammarEngine {
var ignoreList: Set<String> = []
private let checker = NSSpellChecker.shared
private let tag = NSSpellChecker.uniqueSpellDocumentTag()
private let extras = HarperEngine()
func check(_ text: String) -> [Suggestion] {
guard text.count >= 2 else { return [] }
return onMain { self.checkOnMain(text) }
}
private func checkOnMain(_ text: String) -> [Suggestion] {
let ns = text as NSString
let full = NSRange(location: 0, length: ns.length)
let language = checker.language()
var out: [Suggestion] = []
let types = NSTextCheckingResult.CheckingType([.spelling, .grammar]).rawValue
let results = checker.check(
text, range: full, types: types, options: nil,
inSpellDocumentWithTag: tag, orthography: nil, wordCount: nil)
for r in results {
switch r.resultType {
case .spelling:
let word = ns.substring(with: r.range)
if ignoreList.contains(word.lowercased()) { continue }
let fix = checker.correction(
forWordRange: r.range, in: text, language: language, inSpellDocumentWithTag: tag)
?? checker.guesses(
forWordRange: r.range, in: text, language: language,
inSpellDocumentWithTag: tag)?.first
out.append(Suggestion(
location: r.range.location, length: r.range.length,
original: word, replacement: fix ?? word,
kind: .spelling, message: "Possible spelling mistake"))
case .grammar:
let details = r.grammarDetails ?? []
if details.isEmpty {
let phrase = ns.substring(with: r.range)
out.append(Suggestion(
location: r.range.location, length: r.range.length,
original: phrase, replacement: phrase,
kind: .grammar, message: "Grammar issue"))
} else {
for d in details {
var loc = r.range.location, len = r.range.length
if let rv = d[NSGrammarRange] as? NSValue {
let sub = rv.rangeValue
loc = r.range.location + sub.location
len = sub.length
}
guard len > 0, loc + len <= ns.length else { continue }
let phrase = ns.substring(with: NSRange(location: loc, length: len))
let message = (d[NSGrammarUserDescription] as? String) ?? "Grammar issue"
let corrections = (d[NSGrammarCorrections] as? [String]) ?? []
out.append(Suggestion(
location: loc, length: len,
original: phrase, replacement: corrections.first ?? phrase,
kind: .grammar, message: message))
}
}
default:
break
}
}
// Deterministic extras, skipping anything overlapping a system result.
extras.ignoreList = ignoreList
for h in extras.check(text) where !out.contains(where: { overlaps($0.range, h.range) }) {
out.append(h)
}
return out.sorted { $0.location < $1.location }
}
private func overlaps(_ a: NSRange, _ b: NSRange) -> Bool {
NSIntersectionRange(a, b).length > 0
}
private func onMain<T>(_ work: @escaping () -> T) -> T {
Thread.isMainThread ? work() : DispatchQueue.main.sync(execute: work)
}
}

View File

@ -2,17 +2,31 @@ import Foundation
import AppKit import AppKit
import ApplicationServices import ApplicationServices
/// A text area discovered on screen that has at least one grammar/spelling issue.
struct DetectedField: Identifiable {
let id: String
let label: String
let roleName: String
let text: String
let suggestions: [Suggestion]
let element: AXUIElement
let isFocused: Bool
}
/// The always-on, menu-bar–resident monitor. While enabled, it periodically /// The always-on, menu-bar–resident monitor. While enabled, it periodically
/// reads the focused text field (Accessibility API), runs the offline grammar /// scans **every** text area in the frontmost window (Accessibility API), runs
/// engine, and publishes live suggestions — so the menu bar reflects issues as /// the offline grammar engine on each, and publishes the ones with issues — so
/// you type. It stays out of excluded apps/domains and never sends text anywhere. /// GrammaGem reflects problems across the whole screen, not just the focused
/// field. It stays out of excluded apps/domains and never sends text anywhere.
@MainActor @MainActor
final class LiveMonitor: ObservableObject { final class LiveMonitor: ObservableObject {
@Published private(set) var suggestions: [Suggestion] = [] @Published private(set) var fields: [DetectedField] = []
@Published private(set) var activeAppName: String = "" @Published private(set) var scannedCount = 0
@Published private(set) var activeAppName = ""
@Published private(set) var running = false @Published private(set) var running = false
var issueCount: Int { suggestions.count } /// Total number of issues across all detected text areas.
var issueCount: Int { fields.reduce(0) { $0 + $1.suggestions.count } }
private let grammar: GrammarEngine private let grammar: GrammarEngine
private let detector: AppDetector private let detector: AppDetector
@ -23,7 +37,7 @@ final class LiveMonitor: ObservableObject {
private var enabled = false private var enabled = false
private var paused = false private var paused = false
private var lastHash = 0 private var lastHash = 0
private let interval: TimeInterval = 1.3 private let interval: TimeInterval = 1.4
init(grammar: GrammarEngine, detector: AppDetector, exclusions: Exclusions, capture: TextCapture) { init(grammar: GrammarEngine, detector: AppDetector, exclusions: Exclusions, capture: TextCapture) {
self.grammar = grammar self.grammar = grammar
@ -32,24 +46,49 @@ final class LiveMonitor: ObservableObject {
self.capture = capture self.capture = capture
} }
/// Turn live monitoring on/off (driven by the "live underlines" preference). func setEnabled(_ on: Bool) { enabled = on; reconcile() }
func setEnabled(_ on: Bool) { func setPaused(_ p: Bool) { paused = p; reconcile() }
enabled = on
reconcile() /// Re-scan now (e.g. after applying a fix).
func refreshSoon() { lastHash = 0; tick() }
// MARK: - Applying fixes
@discardableResult
func fixAll() -> Int {
let total = fields.reduce(0) { $0 + applyCorrection(to: $1) }
refreshSoon()
return total
} }
/// Global pause (also stops the hotkeys, handled in AppState). @discardableResult
func setPaused(_ p: Bool) { func fix(_ field: DetectedField) -> Int {
paused = p let n = applyCorrection(to: field)
reconcile() refreshSoon()
return n
} }
/// Re-run immediately (e.g. after a "fix all"). func apply(_ suggestion: Suggestion, in field: DetectedField) {
func refreshSoon() { let ns = NSMutableString(string: field.text)
lastHash = 0 guard suggestion.location + suggestion.length <= ns.length else { return }
tick() ns.replaceCharacters(in: suggestion.range, with: suggestion.replacement)
setValue(ns as String, on: field.element)
refreshSoon()
} }
private func applyCorrection(to field: DetectedField) -> Int {
let corrected = grammar.correct(field.text)
guard corrected != field.text else { return 0 }
setValue(corrected, on: field.element)
return field.suggestions.count
}
private func setValue(_ text: String, on element: AXUIElement) {
AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, text as CFTypeRef)
}
// MARK: - Scanning
private func reconcile() { private func reconcile() {
let shouldRun = enabled && !paused let shouldRun = enabled && !paused
if shouldRun && timer == nil { if shouldRun && timer == nil {
@ -58,7 +97,7 @@ final class LiveMonitor: ObservableObject {
let t = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in let t = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
Task { @MainActor in self?.tick() } Task { @MainActor in self?.tick() }
} }
t.tolerance = 0.4 t.tolerance = 0.5
timer = t timer = t
} else if !shouldRun { } else if !shouldRun {
running = false running = false
@ -70,29 +109,52 @@ final class LiveMonitor: ObservableObject {
private func tick() { private func tick() {
guard enabled, !paused, AXIsProcessTrusted() else { clear(); return } guard enabled, !paused, AXIsProcessTrusted() else { clear(); return }
guard let app = NSWorkspace.shared.frontmostApplication else { clear(); return }
activeAppName = app.localizedName ?? ""
let front = detector.frontmost() if exclusions.isBlocked(bundleID: app.bundleIdentifier, domain: detector.frontmostDomain()) {
activeAppName = front?.name ?? ""
// Respect the page blocker.
if exclusions.isBlocked(bundleID: front?.bundleID, domain: detector.frontmostDomain()) {
clear() clear()
return return
} }
guard let field = capture.focusedFieldText() else { clear(); return } let focused = systemFocusedElement()
let text = field.text let scanned = TextFieldScanner.scan(pid: app.processIdentifier, focused: focused)
guard text.count >= 3 else { clear(); return } guard !scanned.isEmpty else { clear(); return }
let h = text.hashValue // Skip re-checking if nothing on screen changed.
if h == lastHash { return } // unchanged since last check var hasher = Hasher()
hasher.combine(app.bundleIdentifier ?? "")
for f in scanned { hasher.combine(f.text) }
let h = hasher.finalize()
if h == lastHash { return }
lastHash = h lastHash = h
suggestions = grammar.check(text) scannedCount = scanned.count
var detected: [DetectedField] = []
for (i, f) in scanned.enumerated() {
let suggestions = grammar.check(f.text)
guard !suggestions.isEmpty else { continue }
detected.append(DetectedField(
id: "\(f.roleName)#\(i)", label: f.label, roleName: f.roleName,
text: f.text, suggestions: suggestions, element: f.element, isFocused: f.isFocused))
}
// Focused field first.
detected.sort { ($0.isFocused ? 0 : 1) < ($1.isFocused ? 0 : 1) }
fields = detected
}
private func systemFocusedElement() -> AXUIElement? {
let system = AXUIElementCreateSystemWide()
var ref: CFTypeRef?
guard AXUIElementCopyAttributeValue(
system, kAXFocusedUIElementAttribute as CFString, &ref) == .success, let ref
else { return nil }
return (ref as! AXUIElement)
} }
private func clear() { private func clear() {
if !suggestions.isEmpty { suggestions = [] } if !fields.isEmpty { fields = [] }
scannedCount = 0
lastHash = 0 lastHash = 0
} }
} }

View File

@ -0,0 +1,91 @@
import Foundation
import AppKit
import ApplicationServices
/// A text element discovered in the frontmost window via the Accessibility tree.
struct ScannedField {
let element: AXUIElement
let roleName: String
let label: String
let text: String
let isFocused: Bool
}
/// Walks the Accessibility tree of the frontmost window and collects every
/// editable text element (text fields, text areas, combo/search boxes) with
/// content — so GrammaGem can check *all* the text on screen, not just the one
/// the cursor is in. Bounded so huge web/Electron trees can't stall the app.
enum TextFieldScanner {
static func scan(
pid: pid_t, focused: AXUIElement?,
maxFields: Int = 30, maxVisited: Int = 4000
) -> [ScannedField] {
let axApp = AXUIElementCreateApplication(pid)
guard let root = window(of: axApp) ?? optional(axApp) else { return [] }
var out: [ScannedField] = []
var visited = 0
walk(root, focused: focused, depth: 0, out: &out, visited: &visited,
maxFields: maxFields, maxVisited: maxVisited)
return out
}
private static func optional(_ el: AXUIElement) -> AXUIElement? { el }
private static func window(of axApp: AXUIElement) -> AXUIElement? {
for attr in [kAXFocusedWindowAttribute, kAXMainWindowAttribute] {
var ref: CFTypeRef?
if AXUIElementCopyAttributeValue(axApp, attr as CFString, &ref) == .success,
let ref { return (ref as! AXUIElement) }
}
return nil
}
private static func walk(
_ el: AXUIElement, focused: AXUIElement?, depth: Int,
out: inout [ScannedField], visited: inout Int, maxFields: Int, maxVisited: Int
) {
if out.count >= maxFields || visited >= maxVisited || depth > 70 { return }
visited += 1
let role = string(el, kAXRoleAttribute) ?? ""
if role == kAXTextFieldRole as String
|| role == kAXTextAreaRole as String
|| role == kAXComboBoxRole as String {
if let value = string(el, kAXValueAttribute),
value.trimmingCharacters(in: .whitespacesAndNewlines).count >= 3 {
let label = string(el, kAXTitleAttribute)
?? string(el, kAXDescriptionAttribute)
?? string(el, kAXPlaceholderValueAttribute)
?? roleLabel(role)
let isFocused = focused != nil && CFEqual(el, focused!)
out.append(ScannedField(
element: el, roleName: roleLabel(role),
label: label, text: value, isFocused: isFocused))
}
}
var childrenRef: CFTypeRef?
if AXUIElementCopyAttributeValue(el, kAXChildrenAttribute as CFString, &childrenRef) == .success,
let kids = childrenRef as? [AXUIElement] {
for k in kids {
if out.count >= maxFields || visited >= maxVisited { break }
walk(k, focused: focused, depth: depth + 1, out: &out,
visited: &visited, maxFields: maxFields, maxVisited: maxVisited)
}
}
}
private static func string(_ el: AXUIElement, _ attr: String) -> String? {
var ref: CFTypeRef?
guard AXUIElementCopyAttributeValue(el, attr as CFString, &ref) == .success else { return nil }
return ref as? String
}
private static func roleLabel(_ role: String) -> String {
switch role {
case kAXTextAreaRole as String: return "Text area"
case kAXComboBoxRole as String: return "Combo box"
default: return "Text field"
}
}
}