mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
- Added LiveMonitor class to scan text areas in the frontmost window for grammar and spelling issues. - Introduced DetectedField struct to represent text areas with issues, including suggestions for corrections. - Implemented functionality to apply corrections and dismiss suggestions. - Added Permissions class to manage Accessibility permissions required for monitoring. - Created SnippetStore for managing text-expansion snippets, including persistence to UserDefaults. - Developed TextCapture class to read and write text from the frontmost application using Accessibility API and clipboard fallback. - Implemented TextFieldScanner to traverse the Accessibility tree and collect editable text elements. - Added TextReplacementCoordinator to orchestrate the text correction process. - Integrated UpdaterManager for handling application updates using Sparkle. - Created comprehensive unit tests for various components, including grammar corrections and entitlement checks.
253 lines
9.9 KiB
Swift
253 lines
9.9 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 scannedWordCount = 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 } }
|
||
|
||
/// Outstanding issues grouped into the user-facing buckets (for filter chips).
|
||
var bucketCounts: [IssueBucket: Int] {
|
||
var counts: [IssueBucket: Int] = [:]
|
||
for field in fields {
|
||
for s in field.suggestions { counts[s.kind.bucket, default: 0] += 1 }
|
||
}
|
||
return counts
|
||
}
|
||
|
||
/// 0–100 on-device cleanliness score for the text currently on screen.
|
||
var writingScore: Int {
|
||
let weighted = fields.reduce(0) { acc, f in
|
||
acc + f.suggestions.reduce(0) { $0 + $1.kind.severity }
|
||
}
|
||
return WritingScore.score(weightedIssues: weighted, words: scannedWordCount)
|
||
}
|
||
|
||
/// Content keys of suggestions the user has dismissed; survives re-scans so a
|
||
/// dismissed issue doesn't reappear on the next poll.
|
||
private var dismissedKeys: Set<String> = []
|
||
|
||
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()
|
||
}
|
||
|
||
// MARK: - Dismissing (hide a suggestion without applying it)
|
||
|
||
/// Hide one suggestion. A cheap, always-available "no" is what makes saying
|
||
/// "yes" (Apply) feel safe. The dismissal is keyed by content so it persists
|
||
/// across re-scans of unchanged text.
|
||
func dismiss(_ suggestion: Suggestion, in field: DetectedField) {
|
||
dismissedKeys.insert(Self.dismissKey(fieldID: field.id, suggestion))
|
||
fields = filterDismissed(fields)
|
||
}
|
||
|
||
private static func dismissKey(fieldID: String, _ s: Suggestion) -> String {
|
||
"\(fieldID)|\(s.location)|\(s.length)|\(s.original)|\(s.replacement)"
|
||
}
|
||
|
||
private func filterDismissed(_ input: [DetectedField]) -> [DetectedField] {
|
||
input.compactMap { f in
|
||
let remaining = f.suggestions.filter {
|
||
!dismissedKeys.contains(Self.dismissKey(fieldID: f.id, $0))
|
||
}
|
||
guard !remaining.isEmpty else { return nil }
|
||
return DetectedField(id: f.id, label: f.label, roleName: f.roleName,
|
||
text: f.text, suggestions: remaining,
|
||
element: f.element, isFocused: f.isFocused)
|
||
}
|
||
}
|
||
|
||
private static func wordCount(_ s: String) -> Int {
|
||
s.split { $0.isWhitespace || $0.isNewline }.count
|
||
}
|
||
|
||
/// 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 }
|
||
|
||
// Total words across ALL scanned fields (clean ones included) so the
|
||
// writing score is honest about the whole on-screen text, not just the
|
||
// fields that happen to have issues.
|
||
scannedWordCount = scanned.reduce(0) { $0 + Self.wordCount($1.text) }
|
||
|
||
// 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 = filterDismissed(detected)
|
||
}
|
||
|
||
private nonisolated static func systemFocusedElement() -> AXUIElement? {
|
||
AX.focusedElement()
|
||
}
|
||
|
||
private func clear() {
|
||
if !fields.isEmpty { fields = [] }
|
||
scannedCount = 0
|
||
scannedWordCount = 0
|
||
lastHash = 0
|
||
}
|
||
}
|