mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
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>
195 lines
7.4 KiB
Swift
195 lines
7.4 KiB
Swift
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
|
||
}
|
||
}
|