grammargem/Sources/GrammaGem/System/LiveMonitor.swift
BigMarc bb096bed7b Always-on live monitoring, page blocker, universal builds
- 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>
2026-06-16 15:17:08 +02:00

99 lines
2.9 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
/// 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
}
}