mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
- LiveMonitor: menu-bar–resident; reads the focused field via the Accessibility API, runs the offline grammar engine, and shows live suggestions + a "Fix all". - Menu-bar icon now reflects status (issue count / paused); added Pause/Resume. - Page Blocker: exclude apps + websites (Safari domain detection) where GrammaGem stays out of the way; hotkeys + monitoring suppressed there. - build.sh now produces a universal (arm64 + x86_64) bundle; sign-notarize.sh is a turnkey codesign→notarize→staple flow reading creds from env/keychain (no secrets). - Clean entitlements plist (AMFI-compatible). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
99 lines
2.9 KiB
Swift
99 lines
2.9 KiB
Swift
import Foundation
|
||
import AppKit
|
||
import ApplicationServices
|
||
|
||
/// The always-on, menu-bar–resident monitor. While enabled, it periodically
|
||
/// reads the focused text field (Accessibility API), runs the offline grammar
|
||
/// engine, and publishes live suggestions — so the menu bar reflects issues as
|
||
/// you type. It stays out of excluded apps/domains and never sends text anywhere.
|
||
@MainActor
|
||
final class LiveMonitor: ObservableObject {
|
||
@Published private(set) var suggestions: [Suggestion] = []
|
||
@Published private(set) var activeAppName: String = ""
|
||
@Published private(set) var running = false
|
||
|
||
var issueCount: Int { 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 lastHash = 0
|
||
private let interval: TimeInterval = 1.3
|
||
|
||
init(grammar: GrammarEngine, detector: AppDetector, exclusions: Exclusions, capture: TextCapture) {
|
||
self.grammar = grammar
|
||
self.detector = detector
|
||
self.exclusions = exclusions
|
||
self.capture = capture
|
||
}
|
||
|
||
/// Turn live monitoring on/off (driven by the "live underlines" preference).
|
||
func setEnabled(_ on: Bool) {
|
||
enabled = on
|
||
reconcile()
|
||
}
|
||
|
||
/// Global pause (also stops the hotkeys, handled in AppState).
|
||
func setPaused(_ p: Bool) {
|
||
paused = p
|
||
reconcile()
|
||
}
|
||
|
||
/// Re-run immediately (e.g. after a "fix all").
|
||
func refreshSoon() {
|
||
lastHash = 0
|
||
tick()
|
||
}
|
||
|
||
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.4
|
||
timer = t
|
||
} else if !shouldRun {
|
||
running = false
|
||
timer?.invalidate()
|
||
timer = nil
|
||
clear()
|
||
}
|
||
}
|
||
|
||
private func tick() {
|
||
guard enabled, !paused, AXIsProcessTrusted() else { clear(); return }
|
||
|
||
let front = detector.frontmost()
|
||
activeAppName = front?.name ?? ""
|
||
|
||
// Respect the page blocker.
|
||
if exclusions.isBlocked(bundleID: front?.bundleID, domain: detector.frontmostDomain()) {
|
||
clear()
|
||
return
|
||
}
|
||
|
||
guard let field = capture.focusedFieldText() else { clear(); return }
|
||
let text = field.text
|
||
guard text.count >= 3 else { clear(); return }
|
||
|
||
let h = text.hashValue
|
||
if h == lastHash { return } // unchanged since last check
|
||
lastHash = h
|
||
|
||
suggestions = grammar.check(text)
|
||
}
|
||
|
||
private func clear() {
|
||
if !suggestions.isEmpty { suggestions = [] }
|
||
lastHash = 0
|
||
}
|
||
}
|