grammargem/Sources/GrammaGem/App/AppState.swift
BigMarc a7bf45b466 Add management window; sales-focused README; FounderGem LLC
- Management app: sidebar window with Dashboard (usage insights), Devices
  (slot usage, deactivate this Mac), Writing Modes (custom + app-aware map),
  Dictionary, Snippets, Shortcuts, AI Model, Privacy, General, License, About.
- New local stores: Preferences (launch-at-login, appearance), UsageStats
  (privacy-friendly local insights), CustomModesStore, SnippetStore.
- License model now tracks device name + activation usage/limit for the
  Devices screen.
- README rewritten as a conversion-focused product page (no internal cost/setup).
- Legal entity set to FounderGem LLC across README, LICENSE, Info.plist, About.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:28:23 +02:00

245 lines
8.0 KiB
Swift

import Foundation
import AppKit
import SwiftUI
/// 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
private let capture: TextCapture
let coordinator: TextReplacementCoordinator
// UI state
@Published var lastStatus: String = "Ready"
@Published var askText: String = ""
@Published var appAwareEnabled: Bool = false
private var onboardingWindow: NSWindow?
private var askWindow: NSWindow?
private var mainWindow: NSWindow?
private init() {
let harper = HarperEngine()
grammar = harper
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()
coordinator = TextReplacementCoordinator(
capture: capture, grammar: harper, 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) }
}
hotkeys.registerDefaults()
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
func handle(_ action: HotkeyManager.Action) async {
switch action {
case .fix:
await runFix()
case .ask:
showAsk()
}
}
func runFix() async {
let outcome = await coordinator.handleFix()
if case .replaced(let text) = outcome { usage.recordCorrection(words: wordCount(text)) }
present(outcome)
}
func runAsk() async {
let instruction = askText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instruction.isEmpty else { 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 {
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)
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)
}
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)
}
}