grammargem/Sources/GrammaGem/System/LiveMonitor.swift
BigMarc 35ae2065b3 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>
2026-06-17 16:08:35 +02:00

195 lines
7.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import AppKit
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 scans
/// **every** text area in the frontmost window and publishes the ones with issues.
///
/// Performance & safety: the Accessibility-tree traversal (the part that can hang
/// 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
final class LiveMonitor: ObservableObject {
@Published private(set) var fields: [DetectedField] = []
@Published private(set) var scannedCount = 0
@Published private(set) var activeAppName = ""
@Published private(set) var running = false
/// 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 detector: AppDetector
private let exclusions: Exclusions
private let capture: TextCapture
private var timer: Timer?
private var enabled = false
private var paused = false
private var scanning = false
private var lastHash = 0
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) {
self.grammar = grammar
self.detector = detector
self.exclusions = exclusions
self.capture = capture
}
func setEnabled(_ on: Bool) { enabled = on; reconcile() }
func setPaused(_ p: Bool) { paused = p; reconcile() }
/// Re-scan now (e.g. after applying a fix), superseding any in-flight scan.
func refreshSoon() { lastHash = 0; tick(force: true) }
// MARK: - Applying fixes (always against the element's LIVE value)
@discardableResult
func fixAll() -> Int {
let total = fields.reduce(0) { $0 + applyCorrection(to: $1) }
refreshSoon()
return total
}
@discardableResult
func fix(_ field: DetectedField) -> Int {
let n = applyCorrection(to: field)
refreshSoon()
return n
}
func apply(_ suggestion: Suggestion, in field: DetectedField) {
// 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 }
ns.replaceCharacters(in: suggestion.range, with: suggestion.replacement)
setValue(ns as String, on: field.element)
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 {
guard let live = capture.currentValue(of: field.element) else { return 0 }
let corrected = grammar.correct(live)
guard corrected != live 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() {
let shouldRun = enabled && !paused
if shouldRun && timer == nil {
running = true
tick()
let t = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
Task { @MainActor in self?.tick() }
}
t.tolerance = 0.5
timer = t
} else if !shouldRun {
running = false
timer?.invalidate()
timer = nil
clear()
}
}
private func tick(force: Bool = false) {
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 }
// Never scan GrammaGem's own windows.
if app.processIdentifier == ownPID { clear(); return }
activeAppName = app.localizedName ?? ""
let bundleID = app.bundleIdentifier
let domain = detector.frontmostDomain()
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) }
}
}
private func ingest(_ scanned: [ScannedField], generation: Int) {
scanning = false
guard generation == scanGeneration else { return } // superseded by a newer scan
guard !scanned.isEmpty else { clear(); return }
// Change detection includes focus + element identity so tabbing between
// same-text fields still updates the focused indicator.
var hasher = Hasher()
for f in scanned {
hasher.combine(f.text)
hasher.combine(f.isFocused)
hasher.combine(f.stableID)
}
let h = hasher.finalize()
if h == lastHash { return }
lastHash = h
scannedCount = scanned.count
var detected: [DetectedField] = []
for f in scanned {
let text = f.text.count > maxCheckChars ? String(f.text.prefix(maxCheckChars)) : f.text
let suggestions = grammar.check(text)
guard !suggestions.isEmpty else { continue }
detected.append(DetectedField(
id: f.stableID, label: f.label, roleName: f.roleName,
text: f.text, suggestions: suggestions, element: f.element, isFocused: f.isFocused))
}
detected.sort { ($0.isFocused ? 0 : 1) < ($1.isFocused ? 0 : 1) }
fields = detected
}
private nonisolated static func systemFocusedElement() -> AXUIElement? {
AX.copyElement(AXUIElementCreateSystemWide(), kAXFocusedUIElementAttribute)
}
private func clear() {
if !fields.isEmpty { fields = [] }
scannedCount = 0
lastHash = 0
}
}