grammargem/Sources/GrammaGem/App/AppState.swift
BigMarc 35ae2065b3 Fix 22 audited bugs: stop hangs/crashes/text-corruption; auto-install model
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>
2026-06-17 16:08:35 +02:00

371 lines
14 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)
// Auto-install the on-device AI model in the background on first use, so
// the AI features are ready without the user hunting for a button. The
// download is resumable/cancelable from the AI Model screen.
if !model.isModelPresent(repo: model.selectedRepo) {
model.startDownload()
}
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()
if !hotkeys.register(id: 1, keyCode: preferences.fixHotkey.keyCode,
modifiers: preferences.fixHotkey.carbonModifiers, action: .fix) {
// The chosen combo was rejected (already claimed) — keep a working
// shortcut by falling back to the default rather than leaving none.
hotkeys.register(id: 1, keyCode: AppConfig.Hotkey.defaultKeyCode,
modifiers: AppConfig.Hotkey.defaultModifiers, action: .fix)
lastStatus = "That Fix shortcut is already in use — kept the default (⌘;)."
}
if !hotkeys.register(id: 2, keyCode: preferences.askHotkey.keyCode,
modifiers: preferences.askHotkey.carbonModifiers, action: .ask) {
hotkeys.register(id: 2, keyCode: AppConfig.Hotkey.askKeyCode,
modifiers: AppConfig.Hotkey.askModifiers, action: .ask)
lastStatus = "That Ask shortcut is already in use — kept the default (⌘')."
}
}
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)
}
}