mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
- SystemGrammarEngine: on-device spelling + grammar via NSSpellChecker (no deps, no network), augmented with deterministic Harper-style rules. Now the default. - TextFieldScanner: walks the frontmost window's Accessibility tree to find every editable text area (bounded for huge web/Electron trees), not just the focused one. - LiveMonitor now publishes per-field issues across the whole window + fix methods (apply one suggestion, fix a field, fix everything) via AX value writes. - LiveCheckView: a panel listing each text area with errors highlighted inline and one-click fixes; opened from the menu bar (Show live issues / Show issues / Fix all). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
354 lines
13 KiB
Swift
354 lines
13 KiB
Swift
import Foundation
|
|
import AppKit
|
|
import SwiftUI
|
|
import Combine
|
|
|
|
/// Root application model. Owns the engines, managers, and the system-wide
|
|
/// coordinator, wires the global hotkeys, and presents the on-demand windows.
|
|
@MainActor
|
|
final class AppState: ObservableObject {
|
|
static let shared = AppState()
|
|
|
|
// Engines (Layer 1 + Layer 2)
|
|
let grammar: GrammarEngine
|
|
let ai: AIEngine
|
|
|
|
// Managers
|
|
let permissions: Permissions
|
|
let model: ModelManager
|
|
let license: LicenseManager
|
|
let gate: FeatureGate
|
|
let detector: AppDetector
|
|
let hotkeys: HotkeyManager
|
|
let dictionary: PersonalDictionary
|
|
let preferences: Preferences
|
|
let usage: UsageStats
|
|
let customModes: CustomModesStore
|
|
let snippets: SnippetStore
|
|
let exclusions: Exclusions
|
|
let liveMonitor: LiveMonitor
|
|
private let capture: TextCapture
|
|
let coordinator: TextReplacementCoordinator
|
|
|
|
// UI state
|
|
@Published var lastStatus: String = "Ready"
|
|
@Published var askText: String = ""
|
|
@Published var appAwareEnabled: Bool = false
|
|
@Published var isPaused: Bool = false
|
|
|
|
private var onboardingWindow: NSWindow?
|
|
private var askWindow: NSWindow?
|
|
private var mainWindow: NSWindow?
|
|
private var liveCheckWindow: NSWindow?
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
private init() {
|
|
let engine = SystemGrammarEngine()
|
|
grammar = engine
|
|
ai = MLXEngine(ready: true)
|
|
permissions = Permissions()
|
|
model = ModelManager()
|
|
license = LicenseManager()
|
|
gate = FeatureGate(license: license)
|
|
detector = AppDetector()
|
|
capture = TextCapture()
|
|
hotkeys = HotkeyManager()
|
|
dictionary = PersonalDictionary()
|
|
preferences = Preferences()
|
|
usage = UsageStats()
|
|
customModes = CustomModesStore()
|
|
snippets = SnippetStore()
|
|
let excl = Exclusions()
|
|
exclusions = excl
|
|
liveMonitor = LiveMonitor(grammar: engine, detector: detector, exclusions: excl, capture: capture)
|
|
coordinator = TextReplacementCoordinator(
|
|
capture: capture, grammar: engine, ai: ai, gate: gate, detector: detector)
|
|
}
|
|
|
|
/// Called once at launch (from the AppDelegate).
|
|
func start() {
|
|
// Menu-bar app: no Dock icon (works even without LSUIElement in Info.plist).
|
|
NSApp.setActivationPolicy(.accessory)
|
|
|
|
hotkeys.onAction = { [weak self] action in
|
|
Task { await self?.handle(action) }
|
|
}
|
|
reregisterHotkeys()
|
|
|
|
// Live-apply preference changes.
|
|
capture.restoreClipboard = preferences.restoreClipboard
|
|
preferences.$restoreClipboard
|
|
.sink { [weak self] value in self?.capture.restoreClipboard = value }
|
|
.store(in: &cancellables)
|
|
preferences.$fixHotkey.dropFirst()
|
|
.sink { [weak self] _ in self?.reregisterHotkeys() }
|
|
.store(in: &cancellables)
|
|
preferences.$askHotkey.dropFirst()
|
|
.sink { [weak self] _ in self?.reregisterHotkeys() }
|
|
.store(in: &cancellables)
|
|
|
|
// Always-on live monitoring (menu-bar resident), driven by the preference.
|
|
liveMonitor.setEnabled(preferences.liveUnderlines)
|
|
preferences.$liveUnderlines.dropFirst()
|
|
.sink { [weak self] on in self?.liveMonitor.setEnabled(on) }
|
|
.store(in: &cancellables)
|
|
|
|
permissions.refresh()
|
|
appAwareEnabled = gate.appAwareEnabled
|
|
syncIgnoreList()
|
|
|
|
if !permissions.accessibilityTrusted {
|
|
showOnboarding()
|
|
}
|
|
|
|
Task { await license.validateIfNeeded() }
|
|
Log.app.info("GrammaGem started. Licensed: \(self.license.isLicensed, privacy: .public)")
|
|
}
|
|
|
|
// MARK: - Hotkey handling
|
|
|
|
/// (Re)register the global hotkeys from the current preferences.
|
|
func reregisterHotkeys() {
|
|
hotkeys.unregisterAll()
|
|
hotkeys.register(id: 1, keyCode: preferences.fixHotkey.keyCode,
|
|
modifiers: preferences.fixHotkey.carbonModifiers, action: .fix)
|
|
hotkeys.register(id: 2, keyCode: preferences.askHotkey.keyCode,
|
|
modifiers: preferences.askHotkey.carbonModifiers, action: .ask)
|
|
}
|
|
|
|
func handle(_ action: HotkeyManager.Action) async {
|
|
switch action {
|
|
case .fix:
|
|
await runFix()
|
|
case .ask:
|
|
showAsk()
|
|
}
|
|
}
|
|
|
|
func runFix() async {
|
|
if let reason = blockReason() { lastStatus = reason; return }
|
|
let outcome = await coordinator.handleFix()
|
|
if case .replaced(let text) = outcome { usage.recordCorrection(words: wordCount(text)) }
|
|
present(outcome)
|
|
}
|
|
|
|
/// Fix every issue in the focused field (the live monitor's "Fix all").
|
|
func fixFocusedField() async {
|
|
if let reason = blockReason() { lastStatus = reason; return }
|
|
let outcome = await coordinator.handleFixFocusedField()
|
|
if case .replaced(let text) = outcome { usage.recordCorrection(words: wordCount(text)) }
|
|
present(outcome)
|
|
liveMonitor.refreshSoon()
|
|
}
|
|
|
|
// MARK: - Live multi-field fixes (from the Live Check panel)
|
|
|
|
func fixAllDetected() {
|
|
if let reason = blockReason() { lastStatus = reason; return }
|
|
let n = liveMonitor.fixAll()
|
|
if n > 0 { usage.recordCorrection(words: n) }
|
|
lastStatus = n > 0 ? "Fixed \(n) issue\(n == 1 ? "" : "s")" : "Nothing to fix"
|
|
}
|
|
|
|
func fixDetectedField(_ field: DetectedField) {
|
|
if let reason = blockReason() { lastStatus = reason; return }
|
|
let n = liveMonitor.fix(field)
|
|
if n > 0 { usage.recordCorrection(words: n) }
|
|
}
|
|
|
|
func applySuggestion(_ suggestion: Suggestion, in field: DetectedField) {
|
|
if let reason = blockReason() { lastStatus = reason; return }
|
|
liveMonitor.apply(suggestion, in: field)
|
|
usage.recordCorrection(words: 1)
|
|
}
|
|
|
|
/// Pause/resume all of GrammaGem (hotkeys + live monitoring).
|
|
func togglePause() {
|
|
isPaused.toggle()
|
|
liveMonitor.setPaused(isPaused)
|
|
lastStatus = isPaused ? "Paused" : "Ready"
|
|
}
|
|
|
|
/// A reason GrammaGem should stay out of the way right now, or nil.
|
|
private func blockReason() -> String? {
|
|
if isPaused { return "GrammaGem is paused." }
|
|
let front = detector.frontmost()
|
|
if exclusions.isBlocked(bundleID: front?.bundleID, domain: detector.frontmostDomain()) {
|
|
return "GrammaGem is off for \(front?.name ?? "this app")."
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runAsk() async {
|
|
let instruction = askText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !instruction.isEmpty else { return }
|
|
if let reason = blockReason() { lastStatus = reason; askWindow?.close(); return }
|
|
let outcome = await coordinator.handleAsk(instruction)
|
|
if case .replaced(let text) = outcome { usage.recordAIAction(words: wordCount(text)) }
|
|
present(outcome)
|
|
askWindow?.close()
|
|
}
|
|
|
|
private func wordCount(_ s: String) -> Int {
|
|
s.split(whereSeparator: { $0.isWhitespace || $0.isNewline }).count
|
|
}
|
|
|
|
/// Apply the Mode suggested by the frontmost app (App-Aware, paid).
|
|
func runAppAwareRewrite() async {
|
|
if let reason = blockReason() { lastStatus = reason; return }
|
|
guard gate.appAwareEnabled else {
|
|
present(.blockedByEntitlement("App-aware switching is part of the lifetime upgrade."))
|
|
return
|
|
}
|
|
guard let mode = detector.suggestedMode() else {
|
|
lastStatus = "No mode mapped for the current app."
|
|
return
|
|
}
|
|
let outcome = await coordinator.handleApplyMode(mode)
|
|
if case .replaced(let text) = outcome { usage.recordAIAction(words: wordCount(text)) }
|
|
present(outcome)
|
|
}
|
|
|
|
private func present(_ outcome: ProcessOutcome) {
|
|
switch outcome {
|
|
case .replaced:
|
|
lastStatus = "Fixed ✓"
|
|
case .noSelection:
|
|
lastStatus = "Select some text first."
|
|
case .blockedByEntitlement(let reason):
|
|
lastStatus = reason
|
|
notify(title: "Upgrade required", body: reason)
|
|
case .failed(let message):
|
|
lastStatus = message
|
|
}
|
|
}
|
|
|
|
private func notify(title: String, body: String) {
|
|
// Lightweight, content-free alert (no user text ever leaves the device).
|
|
let alert = NSAlert()
|
|
alert.messageText = title
|
|
alert.informativeText = body
|
|
alert.addButton(withTitle: "OK")
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
alert.runModal()
|
|
}
|
|
|
|
// MARK: - On-demand windows (AppKit-hosted SwiftUI; reliable for menu-bar apps)
|
|
|
|
func showOnboarding() {
|
|
if onboardingWindow == nil {
|
|
let host = NSHostingController(rootView: OnboardingView().environmentObject(self))
|
|
let win = NSWindow(contentViewController: host)
|
|
win.title = "Welcome to GrammaGem"
|
|
win.styleMask = [.titled, .closable, .fullSizeContentView]
|
|
win.setContentSize(NSSize(width: 540, height: 600))
|
|
win.isReleasedWhenClosed = false
|
|
win.center()
|
|
onboardingWindow = win
|
|
}
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
onboardingWindow?.makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
/// The full management window (devices, modes, settings, insights).
|
|
func showMainWindow(select section: MainSection? = nil) {
|
|
if mainWindow == nil {
|
|
let root = MainWindowView(initialSection: section ?? .dashboard)
|
|
.environmentObject(self)
|
|
.environmentObject(license)
|
|
.environmentObject(gate)
|
|
.environmentObject(model)
|
|
.environmentObject(permissions)
|
|
.environmentObject(dictionary)
|
|
.environmentObject(preferences)
|
|
.environmentObject(usage)
|
|
.environmentObject(customModes)
|
|
.environmentObject(snippets)
|
|
.environmentObject(exclusions)
|
|
.environmentObject(liveMonitor)
|
|
let host = NSHostingController(rootView: root)
|
|
let win = NSWindow(contentViewController: host)
|
|
win.title = "GrammaGem"
|
|
win.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView]
|
|
win.setContentSize(NSSize(width: 940, height: 660))
|
|
win.minSize = NSSize(width: 820, height: 560)
|
|
win.isReleasedWhenClosed = false
|
|
win.center()
|
|
mainWindow = win
|
|
}
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
mainWindow?.makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
/// The Live Check panel: every on-screen text area with grammar issues.
|
|
func showLiveCheck() {
|
|
if liveCheckWindow == nil {
|
|
let root = LiveCheckView()
|
|
.environmentObject(self)
|
|
.environmentObject(liveMonitor)
|
|
let host = NSHostingController(rootView: root)
|
|
let win = NSWindow(contentViewController: host)
|
|
win.title = "Live Check"
|
|
win.styleMask = [.titled, .closable, .miniaturizable, .resizable]
|
|
win.setContentSize(NSSize(width: 560, height: 620))
|
|
win.minSize = NSSize(width: 460, height: 420)
|
|
win.isReleasedWhenClosed = false
|
|
win.center()
|
|
liveCheckWindow = win
|
|
}
|
|
liveMonitor.refreshSoon()
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
liveCheckWindow?.makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
func showAsk() {
|
|
askText = ""
|
|
if askWindow == nil {
|
|
let host = NSHostingController(rootView: AskView().environmentObject(self))
|
|
let win = NSWindow(contentViewController: host)
|
|
win.title = "Ask GrammaGem"
|
|
win.styleMask = [.titled, .closable]
|
|
win.setContentSize(NSSize(width: 460, height: 220))
|
|
win.isReleasedWhenClosed = false
|
|
win.center()
|
|
win.level = .floating
|
|
askWindow = win
|
|
}
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
askWindow?.makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
func showOnboardingDismiss() {
|
|
onboardingWindow?.close()
|
|
}
|
|
|
|
func showAskDismiss() {
|
|
askWindow?.close()
|
|
}
|
|
|
|
// MARK: - Personal dictionary (gated adds)
|
|
|
|
@discardableResult
|
|
func addDictionaryEntry(_ word: String) -> FeatureGate.Decision {
|
|
let decision = gate.canAddDictionaryEntry(currentCount: dictionary.entries.count)
|
|
if case .allowed = decision {
|
|
dictionary.add(word)
|
|
syncIgnoreList()
|
|
}
|
|
return decision
|
|
}
|
|
|
|
func removeDictionaryEntry(_ word: String) {
|
|
dictionary.remove(word)
|
|
syncIgnoreList()
|
|
}
|
|
|
|
private func syncIgnoreList() {
|
|
grammar.ignoreList = Set(dictionary.entries.map { $0.lowercased() })
|
|
}
|
|
|
|
func quit() {
|
|
NSApp.terminate(nil)
|
|
}
|
|
}
|