Fix 22 audited bugs: stop hangs/crashes/text-corruption; auto-install model

Stability (the "super buggy" feel):
- Move the Accessibility-tree traversal OFF the main thread with a per-message
  timeout, so a busy/wedged target app can't beachball or hang GrammaGem.
- Replace every unchecked `as! AXUIElement` force-cast with a CFTypeID-checked
  safe cast (AXSupport), eliminating crashes from misbehaving AX servers.
- Never scan GrammaGem's own windows (self-PID exclusion).
- Generation tokens + in-flight guard so superseded scans/downloads can't clobber state.

Text safety:
- Live fixes now read the element's CURRENT value before writing (and only apply a
  span edit when the field still matches the snapshot), so edits made since the last
  scan are never overwritten. Cap per-field check length.
- Stable per-field identity (AX id / position) so the Live Check list no longer churns.
- Include focus + identity in change-detection so tabbing updates the focused field.

Other audited fixes:
- Page blocker: conservative skip when a browser's URL can't be read.
- Model download: validate on-disk size (reject HTML/truncated 200s), clean up
  partials on cancel/failure, handle URLError.cancelled.
- Licensing: clear a server-confirmed-invalid license instead of honoring grace;
  show the real device limit instead of "0 Macs".
- Hotkey re-register falls back to the default if a chosen combo is taken.
- Daily free-tier counter tracks the live timezone.
- "Fix focused field" ignores non-text controls.

Feature: auto-install the on-device model in the background on first launch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BigMarc 2026-06-17 16:08:35 +02:00
parent 5d1f152fc7
commit 35ae2065b3
13 changed files with 269 additions and 113 deletions

View File

@ -17,6 +17,7 @@ final class ModelManager: ObservableObject {
@Published var selectedRepo: String = AppConfig.Model.defaultRepo @Published var selectedRepo: String = AppConfig.Model.defaultRepo
private var task: Task<Void, Never>? private var task: Task<Void, Never>?
private var generation = 0
init() { init() {
if isModelPresent(repo: selectedRepo) { state = .ready } if isModelPresent(repo: selectedRepo) { state = .ready }
@ -40,25 +41,28 @@ final class ModelManager: ObservableObject {
func startDownload(repo: String? = nil) { func startDownload(repo: String? = nil) {
let target = repo ?? selectedRepo let target = repo ?? selectedRepo
selectedRepo = target selectedRepo = target
if isModelPresent(repo: target) { state = .ready; return }
task?.cancel() task?.cancel()
task = Task { await self.run(repo: target) } generation += 1
let gen = generation
task = Task { await self.run(repo: target, generation: gen) }
} }
func cancel() { func cancel() {
task?.cancel() task?.cancel()
task = nil task = nil
generation += 1 // invalidate any in-flight run's state writes
statusText = "" statusText = ""
state = isModelPresent(repo: selectedRepo) ? .ready : .notDownloaded state = isModelPresent(repo: selectedRepo) ? .ready : .notDownloaded
} }
// MARK: - Download // MARK: - Download
private func run(repo: String) async { private func run(repo: String, generation gen: Int) async {
if isModelPresent(repo: repo) { state = .ready; return } if isModelPresent(repo: repo) { set(gen, .ready, ""); return }
let dir = modelDir(repo) let dir = modelDir(repo)
state = .downloading(progress: 0) set(gen, .downloading(progress: 0), "Preparing…")
statusText = "Preparing…"
do { do {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let files = try await fetchFileList(repo: repo) let files = try await fetchFileList(repo: repo)
@ -68,7 +72,8 @@ final class ModelManager: ObservableObject {
for file in files { for file in files {
try Task.checkCancellation() try Task.checkCancellation()
statusText = "Downloading \(file.path) (\(byteString(file.size)))…" set(gen, .downloading(progress: min(1, Double(done) / Double(total))),
"Downloading \(file.path) (\(byteString(file.size)))…")
let url = URL(string: "\(AppConfig.Model.huggingFaceBase)/\(repo)/resolve/main/\(file.path)")! let url = URL(string: "\(AppConfig.Model.huggingFaceBase)/\(repo)/resolve/main/\(file.path)")!
let (tmp, response) = try await URLSession.shared.download(from: url) let (tmp, response) = try await URLSession.shared.download(from: url)
if let http = response as? HTTPURLResponse, http.statusCode != 200 { if let http = response as? HTTPURLResponse, http.statusCode != 200 {
@ -81,24 +86,48 @@ final class ModelManager: ObservableObject {
try FileManager.default.removeItem(at: dest) try FileManager.default.removeItem(at: dest)
} }
try FileManager.default.moveItem(at: tmp, to: dest) try FileManager.default.moveItem(at: tmp, to: dest)
// Reject a wrong/truncated body (e.g. an HTML interstitial served
// with HTTP 200) by checking the on-disk size against the expected.
if file.size > 0 {
let actual = (try? FileManager.default.attributesOfItem(atPath: dest.path)[.size] as? Int64) ?? nil
if let actual, actual != file.size {
throw ModelError.corrupt(file.path)
}
}
done += file.size done += file.size
state = .downloading(progress: min(1, Double(done) / Double(total))) set(gen, .downloading(progress: min(1, Double(done) / Double(total))), nil)
} }
try Data().write(to: dir.appendingPathComponent(".complete")) try Data().write(to: dir.appendingPathComponent(".complete"))
statusText = "" set(gen, .ready, "")
state = .ready
Log.ai.info("Model downloaded: \(repo, privacy: .public)") Log.ai.info("Model downloaded: \(repo, privacy: .public)")
} catch is CancellationError { } catch is CancellationError {
statusText = "" cleanupPartial(repo: repo)
state = isModelPresent(repo: repo) ? .ready : .notDownloaded set(gen, isModelPresent(repo: repo) ? .ready : .notDownloaded, "")
} catch let urlError as URLError where urlError.code == .cancelled {
cleanupPartial(repo: repo)
set(gen, isModelPresent(repo: repo) ? .ready : .notDownloaded, "")
} catch { } catch {
statusText = "" cleanupPartial(repo: repo)
state = .failed(error.localizedDescription) set(gen, .failed(error.localizedDescription), "")
Log.ai.error("Model download failed: \(error.localizedDescription, privacy: .public)") Log.ai.error("Model download failed: \(error.localizedDescription, privacy: .public)")
} }
} }
/// Apply a state/status update only if this run hasn't been superseded.
private func set(_ gen: Int, _ newState: State, _ status: String?) {
guard gen == generation else { return }
state = newState
if let status { statusText = status }
}
/// Remove an incomplete model directory so partial files don't linger.
private func cleanupPartial(repo: String) {
guard !isModelPresent(repo: repo) else { return }
try? FileManager.default.removeItem(at: modelDir(repo))
}
/// List the repo's files (+ sizes) from the Hugging Face tree API. /// List the repo's files (+ sizes) from the Hugging Face tree API.
private func fetchFileList(repo: String) async throws -> [(path: String, size: Int64)] { private func fetchFileList(repo: String) async throws -> [(path: String, size: Int64)] {
let url = URL(string: "\(AppConfig.Model.huggingFaceBase)/api/models/\(repo)/tree/main?recursive=1")! let url = URL(string: "\(AppConfig.Model.huggingFaceBase)/api/models/\(repo)/tree/main?recursive=1")!
@ -118,10 +147,12 @@ final class ModelManager: ObservableObject {
enum ModelError: LocalizedError { enum ModelError: LocalizedError {
case empty case empty
case http(Int, String) case http(Int, String)
case corrupt(String)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .empty: return "No model files were found for this repository." case .empty: return "No model files were found for this repository."
case .http(let code, let what): return "Download failed (\(code)) while fetching \(what)." case .http(let code, let what): return "Download failed (\(code)) while fetching \(what)."
case .corrupt(let what): return "Downloaded file looked wrong (\(what)); please retry."
} }
} }
} }

View File

@ -93,6 +93,13 @@ final class AppState: ObservableObject {
.sink { [weak self] on in self?.liveMonitor.setEnabled(on) } .sink { [weak self] on in self?.liveMonitor.setEnabled(on) }
.store(in: &cancellables) .store(in: &cancellables)
// Auto-install the on-device AI model in the background on first use, so
// the AI features are ready without the user hunting for a button. The
// download is resumable/cancelable from the AI Model screen.
if !model.isModelPresent(repo: model.selectedRepo) {
model.startDownload()
}
permissions.refresh() permissions.refresh()
appAwareEnabled = gate.appAwareEnabled appAwareEnabled = gate.appAwareEnabled
syncIgnoreList() syncIgnoreList()
@ -110,10 +117,20 @@ final class AppState: ObservableObject {
/// (Re)register the global hotkeys from the current preferences. /// (Re)register the global hotkeys from the current preferences.
func reregisterHotkeys() { func reregisterHotkeys() {
hotkeys.unregisterAll() hotkeys.unregisterAll()
hotkeys.register(id: 1, keyCode: preferences.fixHotkey.keyCode, if !hotkeys.register(id: 1, keyCode: preferences.fixHotkey.keyCode,
modifiers: preferences.fixHotkey.carbonModifiers, action: .fix) modifiers: preferences.fixHotkey.carbonModifiers, action: .fix) {
hotkeys.register(id: 2, keyCode: preferences.askHotkey.keyCode, // The chosen combo was rejected (already claimed) — keep a working
modifiers: preferences.askHotkey.carbonModifiers, action: .ask) // shortcut by falling back to the default rather than leaving none.
hotkeys.register(id: 1, keyCode: AppConfig.Hotkey.defaultKeyCode,
modifiers: AppConfig.Hotkey.defaultModifiers, action: .fix)
lastStatus = "That Fix shortcut is already in use — kept the default (⌘;)."
}
if !hotkeys.register(id: 2, keyCode: preferences.askHotkey.keyCode,
modifiers: preferences.askHotkey.carbonModifiers, action: .ask) {
hotkeys.register(id: 2, keyCode: AppConfig.Hotkey.askKeyCode,
modifiers: AppConfig.Hotkey.askModifiers, action: .ask)
lastStatus = "That Ask shortcut is already in use — kept the default (⌘')."
}
} }
func handle(_ action: HotkeyManager.Action) async { func handle(_ action: HotkeyManager.Action) async {

View File

@ -110,8 +110,8 @@ struct ModelDownloadControls: View {
switch model.state { switch model.state {
case .notDownloaded: case .notDownloaded:
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Button("Download model") { model.startDownload() } Button("Download model now") { model.startDownload() }
Text("Downloads the model files from Hugging Face (about 1–2 GB). One time, then offline.") Text("Installs automatically in the background on first launch (about 1–2 GB from Hugging Face). One time, then fully offline.")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
} }
case .downloading(let progress): case .downloading(let progress):

View File

@ -79,7 +79,7 @@ final class DailyCounter {
let f = DateFormatter() let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd" f.dateFormat = "yyyy-MM-dd"
f.locale = Locale(identifier: "en_US_POSIX") f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = .current // local midnight reset f.timeZone = .autoupdatingCurrent // tracks the live local zone (travel/TZ change)
return f return f
}() }()

View File

@ -18,7 +18,7 @@ final class LemonSqueezyClient {
"licenses/activate", "licenses/activate",
params: ["license_key": key, "instance_name": instanceName]) params: ["license_key": key, "instance_name": instanceName])
if let err = res.error { throw mapServerError(err) } if let err = res.error { throw mapServerError(err, limit: res.license_key?.activation_limit) }
guard res.activated, let instance = res.instance else { guard res.activated, let instance = res.instance else {
throw LicenseError.serverMessage("Activation failed.") throw LicenseError.serverMessage("Activation failed.")
} }
@ -34,7 +34,7 @@ final class LemonSqueezyClient {
if let instanceID { params["instance_id"] = instanceID } if let instanceID { params["instance_id"] = instanceID }
let res: LSValidateResponse = try await post("licenses/validate", params: params) let res: LSValidateResponse = try await post("licenses/validate", params: params)
if let err = res.error { throw mapServerError(err) } if let err = res.error { throw mapServerError(err, limit: res.license_key?.activation_limit) }
let tier = try verifyAndResolveTier(meta: res.meta, licenseKey: res.license_key) let tier = try verifyAndResolveTier(meta: res.meta, licenseKey: res.license_key)
return ValidationResult( return ValidationResult(
valid: res.valid, tier: tier, valid: res.valid, tier: tier,
@ -84,10 +84,10 @@ final class LemonSqueezyClient {
} }
} }
private func mapServerError(_ message: String) -> LicenseError { private func mapServerError(_ message: String, limit: Int? = nil) -> LicenseError {
let m = message.lowercased() let m = message.lowercased()
if m.contains("activation limit") { if m.contains("activation limit") {
return .activationLimitReached(0) return .activationLimitReached(limit ?? 0)
} }
if m.contains("not found") || m.contains("invalid") { if m.contains("not found") || m.contains("invalid") {
return .invalidKey return .invalidKey

View File

@ -78,12 +78,30 @@ final class LicenseManager: ObservableObject {
Log.licensing.notice("License reported invalid by server; clearing.") Log.licensing.notice("License reported invalid by server; clearing.")
clearLocal() clearLocal()
} }
} catch let e as LicenseError where Self.isServerConfirmedInvalid(e) {
// The server actively rejected the key (refunded/disabled/wrong product)
// — drop it now rather than honoring it for the 30-day grace window.
Log.licensing.notice("License rejected by server; clearing.")
clearLocal()
} catch { } catch {
// Offline / transient: keep the cached license under the grace window. // Offline / transient: keep the cached license under the grace window.
Log.licensing.notice("Validation deferred (offline). Cached license stands under grace.") Log.licensing.notice("Validation deferred (offline). Cached license stands under grace.")
} }
} }
/// Distinguish a server-confirmed-invalid license from a transient/offline failure.
private static func isServerConfirmedInvalid(_ error: LicenseError) -> Bool {
switch error {
case .invalidKey, .wrongProduct:
return true
case .serverMessage(let m):
let s = m.lowercased()
return ["disabled", "expired", "inactive", "revoked", "refund"].contains { s.contains($0) }
case .network, .activationLimitReached:
return false
}
}
/// Free a device slot so the license can move to another Mac. /// Free a device slot so the license can move to another Mac.
func deactivateThisDevice() async { func deactivateThisDevice() async {
guard let rec = record else { return } guard let rec = record else { return }

View File

@ -0,0 +1,39 @@
import Foundation
import ApplicationServices
/// Safe Accessibility helpers shared across the System layer.
///
/// `AXUIElementCopyAttributeValue` returning `.success` only guarantees a
/// non-null `CFTypeRef` — NOT that it is an `AXUIElement`. A misbehaving AX
/// server (Electron/Java/custom) can return a different CF type, and a forced
/// `as!` cast then traps and crashes the whole app. These helpers cast safely.
enum AX {
/// Safely bridge a CFTypeRef to AXUIElement, returning nil on type mismatch.
static func element(_ ref: CFTypeRef?) -> AXUIElement? {
guard let ref, CFGetTypeID(ref) == AXUIElementGetTypeID() else { return nil }
return (ref as! AXUIElement) // safe: type id verified above
}
/// Copy an attribute as an `AXUIElement` (nil on failure/type mismatch).
static func copyElement(_ element: AXUIElement, _ attribute: String) -> AXUIElement? {
var ref: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success else {
return nil
}
return AX.element(ref)
}
/// Copy an attribute as a `String` (nil on failure/wrong type).
static func copyString(_ element: AXUIElement, _ attribute: String) -> String? {
var ref: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success else {
return nil
}
return ref as? String
}
/// Bound each cross-process AX message so a wedged target app can't hang us.
static func setMessagingTimeout(_ element: AXUIElement, _ seconds: Float) {
AXUIElementSetMessagingTimeout(element, seconds)
}
}

View File

@ -32,20 +32,25 @@ final class AppDetector {
guard AXIsProcessTrusted(), guard AXIsProcessTrusted(),
let app = NSWorkspace.shared.frontmostApplication else { return nil } let app = NSWorkspace.shared.frontmostApplication else { return nil }
let axApp = AXUIElementCreateApplication(app.processIdentifier) let axApp = AXUIElementCreateApplication(app.processIdentifier)
var winRef: CFTypeRef? AX.setMessagingTimeout(axApp, 0.3)
guard AXUIElementCopyAttributeValue( guard let window = AX.copyElement(axApp, kAXFocusedWindowAttribute) else { return nil }
axApp, kAXFocusedWindowAttribute as CFString, &winRef) == .success, if let urlString = AX.copyString(window, kAXDocumentAttribute),
let winRef
else { return nil }
let window = winRef as! AXUIElement
var docRef: CFTypeRef?
if AXUIElementCopyAttributeValue(
window, kAXDocumentAttribute as CFString, &docRef) == .success,
let urlString = docRef as? String,
let host = URL(string: urlString)?.host { let host = URL(string: urlString)?.host {
return host return host
} }
return nil return nil
} }
/// Known web browsers, where a nil domain means "URL undeterminable" (so the
/// page blocker should be conservative) rather than "not a web page".
private static let browserBundleIDs: Set<String> = [
"com.apple.Safari", "com.google.Chrome", "com.google.Chrome.canary",
"company.thebrowser.Browser", "com.microsoft.edgemac", "com.brave.Browser",
"org.mozilla.firefox", "com.operasoftware.Opera", "com.vivaldi.Vivaldi",
]
func isBrowser(_ bundleID: String?) -> Bool {
guard let bundleID else { return false }
return Self.browserBundleIDs.contains(bundleID)
}
} }

View File

@ -27,6 +27,10 @@ final class Exclusions: ObservableObject {
blockedDomains = d.stringArray(forKey: domainsKey) ?? [] blockedDomains = d.stringArray(forKey: domainsKey) ?? []
} }
/// True if the user has any domain rules (so we should be conservative when
/// a browser's URL can't be read).
var hasDomainRules: Bool { !blockedDomains.isEmpty }
/// Whether GrammaGem should be suppressed for this frontmost context. /// Whether GrammaGem should be suppressed for this frontmost context.
func isBlocked(bundleID: String?, domain: String?) -> Bool { func isBlocked(bundleID: String?, domain: String?) -> Bool {
if let bundleID, blockedApps.contains(bundleID) { return true } if let bundleID, blockedApps.contains(bundleID) { return true }

View File

@ -34,7 +34,8 @@ final class HotkeyManager {
modifiers: AppConfig.Hotkey.askModifiers, action: .ask) modifiers: AppConfig.Hotkey.askModifiers, action: .ask)
} }
func register(id: UInt32, keyCode: UInt32, modifiers: UInt32, action: Action) { @discardableResult
func register(id: UInt32, keyCode: UInt32, modifiers: UInt32, action: Action) -> Bool {
idToAction[id] = action idToAction[id] = action
let hotKeyID = EventHotKeyID(signature: OSType(0x47474D31), id: id) // 'GGM1' let hotKeyID = EventHotKeyID(signature: OSType(0x47474D31), id: id) // 'GGM1'
var ref: EventHotKeyRef? var ref: EventHotKeyRef?
@ -43,9 +44,10 @@ final class HotkeyManager {
GetEventDispatcherTarget(), 0, &ref) GetEventDispatcherTarget(), 0, &ref)
if status == noErr { if status == noErr {
refs.append(ref) refs.append(ref)
} else { return true
Log.system.error("RegisterEventHotKey failed: \(status)")
} }
Log.system.error("RegisterEventHotKey failed: \(status)")
return false
} }
func unregisterAll() { func unregisterAll() {

View File

@ -13,11 +13,14 @@ struct DetectedField: Identifiable {
let isFocused: Bool 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 scans
/// scans **every** text area in the frontmost window (Accessibility API), runs /// **every** text area in the frontmost window and publishes the ones with issues.
/// the offline grammar engine on each, and publishes the ones with issues — so ///
/// GrammaGem reflects problems across the whole screen, not just the focused /// Performance & safety: the Accessibility-tree traversal (the part that can hang
/// field. It stays out of excluded apps/domains and never sends text anywhere. /// on a busy/wedged app) runs OFF the main thread with a per-message timeout; only
/// the bounded spell/grammar pass and publish happen on the main actor. It never
/// scans GrammaGem itself, respects the page blocker, and applies fixes against the
/// element's *live* value so it can't overwrite text typed since the last scan.
@MainActor @MainActor
final class LiveMonitor: ObservableObject { final class LiveMonitor: ObservableObject {
@Published private(set) var fields: [DetectedField] = [] @Published private(set) var fields: [DetectedField] = []
@ -36,8 +39,12 @@ final class LiveMonitor: ObservableObject {
private var timer: Timer? private var timer: Timer?
private var enabled = false private var enabled = false
private var paused = false private var paused = false
private var scanning = false
private var lastHash = 0 private var lastHash = 0
private let interval: TimeInterval = 1.4 private var scanGeneration = 0
private let interval: TimeInterval = 1.6
private let maxCheckChars = 4000
private let ownPID = ProcessInfo.processInfo.processIdentifier
init(grammar: GrammarEngine, detector: AppDetector, exclusions: Exclusions, capture: TextCapture) { init(grammar: GrammarEngine, detector: AppDetector, exclusions: Exclusions, capture: TextCapture) {
self.grammar = grammar self.grammar = grammar
@ -49,10 +56,10 @@ final class LiveMonitor: ObservableObject {
func setEnabled(_ on: Bool) { enabled = on; reconcile() } func setEnabled(_ on: Bool) { enabled = on; reconcile() }
func setPaused(_ p: Bool) { paused = p; reconcile() } func setPaused(_ p: Bool) { paused = p; reconcile() }
/// Re-scan now (e.g. after applying a fix). /// Re-scan now (e.g. after applying a fix), superseding any in-flight scan.
func refreshSoon() { lastHash = 0; tick() } func refreshSoon() { lastHash = 0; tick(force: true) }
// MARK: - Applying fixes // MARK: - Applying fixes (always against the element's LIVE value)
@discardableResult @discardableResult
func fixAll() -> Int { func fixAll() -> Int {
@ -69,16 +76,24 @@ final class LiveMonitor: ObservableObject {
} }
func apply(_ suggestion: Suggestion, in field: DetectedField) { func apply(_ suggestion: Suggestion, in field: DetectedField) {
let ns = NSMutableString(string: field.text) // Only apply a span edit if the field still matches the snapshot the
// suggestion offsets were computed against; otherwise re-scan instead of
// corrupting text the user has since changed.
guard let live = capture.currentValue(of: field.element) else { return }
guard live == field.text else { refreshSoon(); return }
let ns = NSMutableString(string: live)
guard suggestion.location + suggestion.length <= ns.length else { return } guard suggestion.location + suggestion.length <= ns.length else { return }
ns.replaceCharacters(in: suggestion.range, with: suggestion.replacement) ns.replaceCharacters(in: suggestion.range, with: suggestion.replacement)
setValue(ns as String, on: field.element) setValue(ns as String, on: field.element)
refreshSoon() refreshSoon()
} }
/// Correct the element's *current* value (never a stale snapshot), so any
/// keystrokes entered since the scan are preserved.
private func applyCorrection(to field: DetectedField) -> Int { private func applyCorrection(to field: DetectedField) -> Int {
let corrected = grammar.correct(field.text) guard let live = capture.currentValue(of: field.element) else { return 0 }
guard corrected != field.text else { return 0 } let corrected = grammar.correct(live)
guard corrected != live else { return 0 }
setValue(corrected, on: field.element) setValue(corrected, on: field.element)
return field.suggestions.count return field.suggestions.count
} }
@ -107,49 +122,68 @@ final class LiveMonitor: ObservableObject {
} }
} }
private func tick() { private func tick(force: Bool = false) {
guard enabled, !paused, AXIsProcessTrusted() else { clear(); return } guard enabled, !paused, AXIsProcessTrusted() else { clear(); return }
if scanning && !force { return } // don't pile up scans
guard let app = NSWorkspace.shared.frontmostApplication else { clear(); return } guard let app = NSWorkspace.shared.frontmostApplication else { clear(); return }
// Never scan GrammaGem's own windows.
if app.processIdentifier == ownPID { clear(); return }
activeAppName = app.localizedName ?? "" activeAppName = app.localizedName ?? ""
if exclusions.isBlocked(bundleID: app.bundleIdentifier, domain: detector.frontmostDomain()) { let bundleID = app.bundleIdentifier
clear() let domain = detector.frontmostDomain()
return if exclusions.isBlocked(bundleID: bundleID, domain: domain) { clear(); return }
// Conservative: a blocked-domain user in a browser whose URL we can't read
// should not be monitored rather than risk leaking a sensitive page.
if domain == nil, detector.isBrowser(bundleID), exclusions.hasDomainRules { clear(); return }
let pid = app.processIdentifier
scanGeneration += 1
let generation = scanGeneration
scanning = true
// Heavy AX traversal off the main thread.
Task.detached(priority: .utility) {
let focused = LiveMonitor.systemFocusedElement()
let scanned = TextFieldScanner.scan(pid: pid, focused: focused)
await MainActor.run { self.ingest(scanned, generation: generation) }
}
} }
let focused = systemFocusedElement() private func ingest(_ scanned: [ScannedField], generation: Int) {
let scanned = TextFieldScanner.scan(pid: app.processIdentifier, focused: focused) scanning = false
guard generation == scanGeneration else { return } // superseded by a newer scan
guard !scanned.isEmpty else { clear(); return } guard !scanned.isEmpty else { clear(); return }
// Skip re-checking if nothing on screen changed. // Change detection includes focus + element identity so tabbing between
// same-text fields still updates the focused indicator.
var hasher = Hasher() var hasher = Hasher()
hasher.combine(app.bundleIdentifier ?? "") for f in scanned {
for f in scanned { hasher.combine(f.text) } hasher.combine(f.text)
hasher.combine(f.isFocused)
hasher.combine(f.stableID)
}
let h = hasher.finalize() let h = hasher.finalize()
if h == lastHash { return } if h == lastHash { return }
lastHash = h lastHash = h
scannedCount = scanned.count scannedCount = scanned.count
var detected: [DetectedField] = [] var detected: [DetectedField] = []
for (i, f) in scanned.enumerated() { for f in scanned {
let suggestions = grammar.check(f.text) let text = f.text.count > maxCheckChars ? String(f.text.prefix(maxCheckChars)) : f.text
let suggestions = grammar.check(text)
guard !suggestions.isEmpty else { continue } guard !suggestions.isEmpty else { continue }
detected.append(DetectedField( detected.append(DetectedField(
id: "\(f.roleName)#\(i)", label: f.label, roleName: f.roleName, id: f.stableID, label: f.label, roleName: f.roleName,
text: f.text, suggestions: suggestions, element: f.element, isFocused: f.isFocused)) text: f.text, suggestions: suggestions, element: f.element, isFocused: f.isFocused))
} }
// Focused field first.
detected.sort { ($0.isFocused ? 0 : 1) < ($1.isFocused ? 0 : 1) } detected.sort { ($0.isFocused ? 0 : 1) < ($1.isFocused ? 0 : 1) }
fields = detected fields = detected
} }
private func systemFocusedElement() -> AXUIElement? { private nonisolated static func systemFocusedElement() -> AXUIElement? {
let system = AXUIElementCreateSystemWide() AX.copyElement(AXUIElementCreateSystemWide(), kAXFocusedUIElementAttribute)
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() {

View File

@ -55,13 +55,7 @@ final class TextCapture {
guard AXIsProcessTrusted() else { return nil } guard AXIsProcessTrusted() else { return nil }
let systemWide = AXUIElementCreateSystemWide() let systemWide = AXUIElementCreateSystemWide()
var focusedRef: CFTypeRef? guard let focused = AX.copyElement(systemWide, kAXFocusedUIElementAttribute) else { return nil }
guard AXUIElementCopyAttributeValue(
systemWide, kAXFocusedUIElementAttribute as CFString, &focusedRef) == .success,
let focusedRef
else { return nil }
// Force-cast through CFTypeRef to AXUIElement (same CF type).
let focused = focusedRef as! AXUIElement
var valueRef: CFTypeRef? var valueRef: CFTypeRef?
guard AXUIElementCopyAttributeValue( guard AXUIElementCopyAttributeValue(
@ -100,21 +94,25 @@ final class TextCapture {
func focusedFieldText() -> (text: String, element: AXUIElement)? { func focusedFieldText() -> (text: String, element: AXUIElement)? {
guard AXIsProcessTrusted() else { return nil } guard AXIsProcessTrusted() else { return nil }
let systemWide = AXUIElementCreateSystemWide() let systemWide = AXUIElementCreateSystemWide()
var focusedRef: CFTypeRef? guard let focused = AX.copyElement(systemWide, kAXFocusedUIElementAttribute) else { return nil }
guard AXUIElementCopyAttributeValue(
systemWide, kAXFocusedUIElementAttribute as CFString, &focusedRef) == .success,
let focusedRef
else { return nil }
let focused = focusedRef as! AXUIElement
var valueRef: CFTypeRef? // Only treat genuine editable text roles as fixable, so we don't report
guard AXUIElementCopyAttributeValue( // "nothing selected" failures on sliders/steppers/custom controls.
focused, kAXValueAttribute as CFString, &valueRef) == .success, let role = AX.copyString(focused, kAXRoleAttribute)
let text = valueRef as? String guard role == kAXTextFieldRole as String
|| role == kAXTextAreaRole as String
|| role == kAXComboBoxRole as String
else { return nil } else { return nil }
guard let text = AX.copyString(focused, kAXValueAttribute) else { return nil }
return (text, focused) return (text, focused)
} }
/// Re-read an element's current value (used to avoid writing stale text).
func currentValue(of element: AXUIElement) -> String? {
AX.copyString(element, kAXValueAttribute)
}
@discardableResult @discardableResult
func setFocusedField(_ text: String, element: AXUIElement) -> Bool { func setFocusedField(_ text: String, element: AXUIElement) -> Bool {
AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, text as CFTypeRef) == .success AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, text as CFTypeRef) == .success

View File

@ -9,19 +9,26 @@ struct ScannedField {
let label: String let label: String
let text: String let text: String
let isFocused: Bool let isFocused: Bool
/// Stable-ish identity for SwiftUI: AX identifier if present, else role+label+position.
let stableID: String
} }
/// Walks the Accessibility tree of the frontmost window and collects every /// Walks the Accessibility tree of the frontmost window and collects every
/// editable text element (text fields, text areas, combo/search boxes) with /// 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 /// 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. /// the cursor is in. Bounded so huge web/Electron trees can't stall the app, and
/// every cross-process message has a timeout so a wedged app can't hang the scan.
///
/// Safe to call off the main thread (AXUIElement APIs are thread-safe).
enum TextFieldScanner { enum TextFieldScanner {
static func scan( static func scan(
pid: pid_t, focused: AXUIElement?, pid: pid_t, focused: AXUIElement?,
maxFields: Int = 30, maxVisited: Int = 4000 maxFields: Int = 30, maxVisited: Int = 3000
) -> [ScannedField] { ) -> [ScannedField] {
let axApp = AXUIElementCreateApplication(pid) let axApp = AXUIElementCreateApplication(pid)
guard let root = window(of: axApp) ?? optional(axApp) else { return [] } AX.setMessagingTimeout(axApp, 0.3)
guard let root = AX.copyElement(axApp, kAXFocusedWindowAttribute)
?? AX.copyElement(axApp, kAXMainWindowAttribute) else { return [] }
var out: [ScannedField] = [] var out: [ScannedField] = []
var visited = 0 var visited = 0
walk(root, focused: focused, depth: 0, out: &out, visited: &visited, walk(root, focused: focused, depth: 0, out: &out, visited: &visited,
@ -29,17 +36,6 @@ enum TextFieldScanner {
return out 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( private static func walk(
_ el: AXUIElement, focused: AXUIElement?, depth: Int, _ el: AXUIElement, focused: AXUIElement?, depth: Int,
out: inout [ScannedField], visited: inout Int, maxFields: Int, maxVisited: Int out: inout [ScannedField], visited: inout Int, maxFields: Int, maxVisited: Int
@ -47,20 +43,21 @@ enum TextFieldScanner {
if out.count >= maxFields || visited >= maxVisited || depth > 70 { return } if out.count >= maxFields || visited >= maxVisited || depth > 70 { return }
visited += 1 visited += 1
let role = string(el, kAXRoleAttribute) ?? "" let role = AX.copyString(el, kAXRoleAttribute) ?? ""
if role == kAXTextFieldRole as String if role == kAXTextFieldRole as String
|| role == kAXTextAreaRole as String || role == kAXTextAreaRole as String
|| role == kAXComboBoxRole as String { || role == kAXComboBoxRole as String {
if let value = string(el, kAXValueAttribute), if let value = AX.copyString(el, kAXValueAttribute),
value.trimmingCharacters(in: .whitespacesAndNewlines).count >= 3 { value.trimmingCharacters(in: .whitespacesAndNewlines).count >= 3 {
let label = string(el, kAXTitleAttribute) let label = AX.copyString(el, kAXTitleAttribute)
?? string(el, kAXDescriptionAttribute) ?? AX.copyString(el, kAXDescriptionAttribute)
?? string(el, kAXPlaceholderValueAttribute) ?? AX.copyString(el, kAXPlaceholderValueAttribute)
?? roleLabel(role) ?? roleLabel(role)
let isFocused = focused != nil && CFEqual(el, focused!) let isFocused = focused != nil && CFEqual(el, focused!)
out.append(ScannedField( out.append(ScannedField(
element: el, roleName: roleLabel(role), element: el, roleName: roleLabel(role),
label: label, text: value, isFocused: isFocused)) label: label, text: value, isFocused: isFocused,
stableID: identity(of: el, role: role, label: label)))
} }
} }
@ -75,10 +72,21 @@ enum TextFieldScanner {
} }
} }
private static func string(_ el: AXUIElement, _ attr: String) -> String? { /// A SwiftUI-stable identity that survives re-scans and text edits: prefer the
var ref: CFTypeRef? /// AX identifier, else fall back to role + label + on-screen position.
guard AXUIElementCopyAttributeValue(el, attr as CFString, &ref) == .success else { return nil } private static func identity(of el: AXUIElement, role: String, label: String) -> String {
return ref as? String if let axID = AX.copyString(el, kAXIdentifierAttribute), !axID.isEmpty {
return "id:\(axID)"
}
var posRef: CFTypeRef?
if AXUIElementCopyAttributeValue(el, kAXPositionAttribute as CFString, &posRef) == .success,
let posRef, CFGetTypeID(posRef) == AXValueGetTypeID() {
var point = CGPoint.zero
if AXValueGetValue(posRef as! AXValue, .cgPoint, &point) {
return "pos:\(role)|\(label)|\(Int(point.x)),\(Int(point.y))"
}
}
return "rl:\(role)|\(label)"
} }
private static func roleLabel(_ role: String) -> String { private static func roleLabel(_ role: String) -> String {