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() private init() { let engine = SystemGrammarEngine() grammar = engine // The on-device LLM loads weights from ModelManager's local snapshot (if a // complete download is present) — never the network. Resolved lazily on use. ai = MLXEngine(modelDirectoryProvider: { ModelManager.completedModelDirectory(repo: AppConfig.Model.defaultRepo) }) 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) } /// Hide a single suggestion without changing the user's text. func dismissSuggestion(_ suggestion: Suggestion, in field: DetectedField) { liveMonitor.dismiss(suggestion, in: field) } /// Add a flagged word to the personal dictionary so it's never corrected /// again (gated by the free cap), then re-scan so it disappears now. @discardableResult func addWordToDictionary(_ word: String) -> FeatureGate.Decision { let decision = addDictionaryEntry(word) switch decision { case .allowed: lastStatus = "Added “\(word)” to your dictionary" liveMonitor.refreshSoon() case .denied(let reason): lastStatus = reason } return decision } /// 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: 560, height: 720)) 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) } }