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 } }