diff --git a/AppSupport/Info.plist b/AppSupport/Info.plist index e11b17c..dd71d27 100644 --- a/AppSupport/Info.plist +++ b/AppSupport/Info.plist @@ -36,7 +36,7 @@ LSUIElement NSHumanReadableCopyright - © 2026 GrammaGem. Built in public. + © 2026 FounderGem LLC. Built in public. NSPrincipalClass NSApplication diff --git a/LICENSE b/LICENSE index 1f39cd7..4caaaf6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 GrammaGem +Copyright (c) 2026 FounderGem LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 59a9136..c4b5b01 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,102 @@ -# GrammaGem (macOS app) +
-A private, **on-device** writing assistant for macOS — a one-time-purchase -replacement for Grammarly. Fix grammar, sharpen tone, and rewrite anything in -**any app** via a global hotkey. Your words never leave your Mac. +# ✒️ GrammaGem -Built in public. The grammar core is open-source; the repo is permissively -licensed. We sell the **signed, notarized, auto-updating build + a multi-device -license + support** — not code secrecy. +### Write it once. Get it right. + +**The private, on-device writing assistant for Mac.** +Fix grammar, sharpen tone, and rewrite anything — in every app — for one payment, forever. + +![macOS 14+](https://img.shields.io/badge/macOS-14%2B-101F1A?logo=apple&logoColor=white) +![Apple Silicon](https://img.shields.io/badge/Apple%20Silicon-optimized-0E7C5A) +![One-time purchase](https://img.shields.io/badge/pricing-one--time-C9A24B) +![License: MIT](https://img.shields.io/badge/license-MIT-0E7C5A) + +[**⬇️ Download GrammaGem**](https://grammagem.app/#pricing) · [Docs](https://grammagem.app/docs) · [How it works](https://grammagem.app) + +
--- -## Architecture (two layers) +## Stop renting your grammar checker. -``` -GrammaGem (native menu-bar app, Swift/SwiftUI) -├─ System integration global hotkey → capture selected text (AX API / -│ clipboard fallback) → route to engine → replace in place -├─ Layer 1: GRAMMAR Harper (Rust, Apache-2.0) — spelling/grammar/punctuation, -│ deterministic, <10ms, offline. Powers live underlines. FREE. -├─ Layer 2: AI MLX (Apple Silicon) + a small instruct model — rewrite, -│ tone, "Ask", translate, Mode rewriting. Closes the gap. PAID. -└─ Entitlements + Licensing free/paid gate · device-cap activation (Lemon Squeezy) -``` +Grammarly costs around **$144 every year**, reads everything you type, and sends it to their servers. GrammaGem does the same job — **better** — entirely on your Mac, for a **single payment**. -Network is touched **only** for: first-run model download (Hugging Face), -license activate/validate (Lemon Squeezy), and update checks (Sparkle/GitHub -Releases). **Writing never requires internet. No telemetry.** +Select text in any app, press one hotkey, and it’s polished in place. No copy-paste, no switching windows, and **nothing ever leaves your machine**. -## Repo layout +> “One payment and it’s mine forever — such a bargain.” 👍 -``` -mac/ -├─ Package.swift SwiftPM executable (Apple-frameworks only → builds out of the box) -├─ Sources/GrammaGem/ -│ ├─ App/ @main app, menu bar, onboarding, settings, Ask popover, AppState -│ ├─ System/ hotkey (Carbon), AX capture/replace + clipboard fallback, app detection, permissions -│ ├─ Grammar/ GrammarEngine protocol + Harper stub + personal dictionary -│ ├─ AI/ AIEngine protocol + MLX stub + model manager + Writing Modes -│ ├─ Licensing/ Lemon Squeezy client, device fingerprint, Keychain, license manager -│ ├─ Entitlements/ free/paid gate + daily AI counter -│ └─ Core/ config, models, logging -├─ Tests/GrammaGemTests/ unit tests (entitlements, Harper rules, fingerprint, modes) -├─ Resources/Modes/ Mode prompt presets (paid ones tuned in release only) -├─ AppSupport/ Info.plist (LSUIElement) + entitlements -├─ scripts/ build → sign/notarize → release(Sparkle/GitHub) -└─ harper-ffi/ Rust↔Swift FFI for the real Harper core (placeholder) -``` +--- -## Build & run +## Why people switch -```bash -cd mac -swift build # compiles the app (Apple frameworks only) -swift test # runs the unit tests -swift run GrammaGem # runs as a menu-bar (accessory) app +| | Grammarly | **GrammaGem** | +|---|:---:|:---:| +| **Price** | ~$144 / year, forever | **Pay once** | +| **Your words** | Sent to the cloud | **Never leave your Mac** | +| **Works offline** | ❌ | ✅ | +| **Where it works** | Where the extension reaches | **Every app, system-wide** | +| **Speed** | Round-trip to a server | **Instant, on-device** | -# Package a .app bundle you can double-click: -./scripts/build.sh # → dist/GrammaGem.app -``` +--- -On first launch, grant **Accessibility** when prompted (System Settings → -Privacy & Security → Accessibility). Without it the app degrades gracefully to a -manual paste-in window. Default hotkeys: **⌘;** fix selection, **⌘'** Ask. +## What you get -> Prefer Xcode? Open `Package.swift` directly in Xcode 26 (File → Open). For a -> production app target, point it at `AppSupport/Info.plist` + entitlements. +- ⚡ **Instant corrections** — grammar, spelling, punctuation, and clarity, fixed the moment you ask. +- 🔒 **Private by design** — everything runs on your Mac’s Neural Engine. Your writing is never uploaded, logged, or used to train a model. +- ⌨️ **One global hotkey** — fix or rewrite the selected text in any app: Mail, Slack, Notion, Chrome, Messages, VS Code, and more. +- ✨ **Ask & rewrite** — “make it shorter,” “more formal,” “translate to German” — done in place. +- 🎚️ **Writing Modes** — Email, Post, Team Chat, Academic, Code — switch the tone instantly, or let GrammaGem pick the right one for the app you’re in. +- 📖 **Personal dictionary** — teach it your names, brand terms, and jargon once; it stops “correcting” them forever. +- 🖥️ **A clean management app** — manage your devices, shortcuts, modes, and privacy from one tidy window. -## What's real vs. stubbed +--- -This codebase implements the full **architecture and product logic** end-to-end, -with the heavyweight native integrations behind clean protocol seams so it -compiles with **zero external dependencies**. Search `TODO(real-integration)`. +## Buy once. Write forever. -| Area | Status | -|------|--------| -| System-wide capture → transform → replace loop | ✅ Real (AX + clipboard fallback, CGEvent ⌘C/⌘V, restore clipboard) | -| Global hotkeys | ✅ Real (Carbon `RegisterEventHotKey`) | -| Active-app detection + Mode mapping | ✅ Real (`NSWorkspace`) | -| Permissions onboarding | ✅ Real (`AXIsProcessTrusted`, deep-links, auto-advance) | -| Entitlements gate + daily AI counter (resets local midnight) | ✅ Real | -| Device fingerprint (IOPlatformUUID → SHA-256) | ✅ Real (IOKit + CryptoKit) | -| Keychain license storage | ✅ Real (Security framework) | -| Lemon Squeezy activate/validate/deactivate + hard-verify + offline grace | ✅ Real client (needs your store/product/variant IDs) | -| Personal dictionary (25 free / unlimited paid) | ✅ Real | -| Menu bar UI, Settings, Ask popover | ✅ Real (SwiftUI) | -| **Harper grammar core** | 🔌 Stub: small pure-Swift rule set; swap for the Rust FFI (`harper-ffi/`) | -| **MLX local LLM** | 🔌 Stub: deterministic transforms; swap for `mlx-swift` + downloaded model | -| **Model download** | 🔌 Stub: simulated progress; swap for real Hugging Face streaming | -| KeyboardShortcuts recorder UI / Sparkle auto-update | 🔌 Documented seams (scripts + Package.swift comments) | +No subscription. No per-seat fees. One payment, lifetime updates. -### Before shipping -1. Set real IDs in [`Core/AppConfig.swift`](Sources/GrammaGem/Core/AppConfig.swift): - Lemon Squeezy `expectedStoreID` / `expectedProductID` / per-tier `variantID` - (set each variant's **activation limit** to the device cap: Solo 1 / Personal 2 / Studio 4), - `bundleIdentifier`, `supportEmail`, model portal URL. -2. Replace the Harper stub with the FFI (`harper-ffi/README.md`). -3. Replace the MLX stub + model manager with `mlx-swift` and real HF download. -4. Add `KeyboardShortcuts` + `Sparkle` SwiftPM deps (commented in `Package.swift`). -5. Codesign + notarize via `scripts/sign-notarize.sh` (needs Apple Developer Program). +| Plan | Price | Devices | +|------|:-----:|:-------:| +| **Solo** | $39 | 1 Mac | +| **Personal** ⭐ | $59 | 2 Macs | +| **Studio** | $89 | 4 Macs | +| **Startup License** | $159 | 10 Macs | -## Honest gaps (by design) +*Apple Silicon · macOS 14+ · 14-day money-back guarantee, no questions asked.* -- **No plagiarism detection.** It needs a server-side corpus + live index, which - breaks both the $0-cost rule and the privacy promise. Out of scope, not faked. -- **The gate is intentionally soft.** Because the code is public, a technical - user could compile a build with the gate removed — that's the VoiceInk model - and it's fine. The product is the signed build + license + support, not secrecy. +
-## Cost model — $0 marginal cost per customer +[**⬇️ Get GrammaGem**](https://grammagem.app/#pricing) -| Item | Cost | -|------|------| -| Server / database | $0 (none exists) | -| AI inference (on device) | $0 | -| Model hosting (Hugging Face) | $0 | -| Update hosting (GitHub Releases) | $0 | -| License validation (Lemon Squeezy) | $0 (included) | -| Apple Developer Program | $99 / year (fixed) | -| Payment processor | ~5% + fee per sale | +
-## License +--- -Permissive (MIT/Apache-2.0) to maximize trust and stars. Monetization is the -signed binary + multi-device license, not the source. +## FAQ + +**Is my writing really private?** +Yes. Every correction and rewrite runs on your Mac. Your text is never uploaded, logged, or used for training. + +**Do I need internet?** +No. Core features work fully offline. + +**Is this a subscription?** +No — it’s a one-time purchase with lifetime updates and a 14-day money-back guarantee. + +**What about plagiarism detection?** +GrammaGem is private-first, so it doesn’t do cloud plagiarism scanning — that would require sending your writing to a server. Everything else you’d expect is here. + +--- + +## Built in public + +GrammaGem is open source under the **MIT License**. We believe a privacy tool should be one you can actually inspect. Issues and pull requests are welcome. + +
+ +**[grammagem.app](https://grammagem.app)** + +© 2026 FounderGem LLC · 1309 Coffeen Avenue STE 1200, Sheridan, WY 82801, USA + +
diff --git a/Sources/GrammaGem/AI/CustomModesStore.swift b/Sources/GrammaGem/AI/CustomModesStore.swift new file mode 100644 index 0000000..9c15c09 --- /dev/null +++ b/Sources/GrammaGem/AI/CustomModesStore.swift @@ -0,0 +1,71 @@ +import Foundation + +/// User-created Writing Modes (paid) plus per-app Mode overrides for App-Aware +/// switching. Persisted locally; the built-in modes live in `ModeRegistry`. +@MainActor +final class CustomModesStore: ObservableObject { + @Published private(set) var customModes: [WritingMode] + /// bundleID -> mode id (overrides the default App-Aware mapping). + @Published private(set) var appOverrides: [String: String] + + private let modesKey = "GrammaGem.customModes" + private let mapKey = "GrammaGem.appModeOverrides" + private let d = UserDefaults.standard + + init() { + if let raw = d.data(forKey: modesKey), + let decoded = try? JSONDecoder().decode([WritingMode].self, from: raw) { + customModes = decoded + } else { + customModes = [] + } + appOverrides = (d.dictionary(forKey: mapKey) as? [String: String]) ?? [:] + } + + /// Built-in + custom modes (custom ones are always paid). + func allModes() -> [WritingMode] { ModeRegistry.all + customModes } + + func addCustom(name: String, prompt: String) { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let mode = WritingMode( + id: "custom-\(UUID().uuidString.prefix(8))", + name: trimmed, + systemPrompt: prompt.trimmingCharacters(in: .whitespacesAndNewlines), + isPaid: true, + lengthCap: nil, + autoFormat: "Custom" + ) + customModes.append(mode) + persistModes() + } + + func removeCustom(id: String) { + customModes.removeAll { $0.id == id } + appOverrides = appOverrides.filter { $0.value != id } + persistModes() + persistMap() + } + + func setOverride(bundleID: String, modeID: String) { + let b = bundleID.trimmingCharacters(in: .whitespaces) + guard !b.isEmpty else { return } + appOverrides[b] = modeID + persistMap() + } + + func clearOverride(bundleID: String) { + appOverrides.removeValue(forKey: bundleID) + persistMap() + } + + private func persistModes() { + if let raw = try? JSONEncoder().encode(customModes) { + d.set(raw, forKey: modesKey) + } + } + + private func persistMap() { + d.set(appOverrides, forKey: mapKey) + } +} diff --git a/Sources/GrammaGem/App/AppState.swift b/Sources/GrammaGem/App/AppState.swift index 1ddb00a..fbd5219 100644 --- a/Sources/GrammaGem/App/AppState.swift +++ b/Sources/GrammaGem/App/AppState.swift @@ -20,6 +20,10 @@ final class AppState: ObservableObject { 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 @@ -30,6 +34,7 @@ final class AppState: ObservableObject { private var onboardingWindow: NSWindow? private var askWindow: NSWindow? + private var mainWindow: NSWindow? private init() { let harper = HarperEngine() @@ -43,6 +48,10 @@ final class AppState: ObservableObject { 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) } @@ -81,16 +90,24 @@ final class AppState: ObservableObject { } func runFix() async { - present(await coordinator.handleFix()) + 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 } - present(await coordinator.handleAsk(instruction)) + 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 { @@ -101,7 +118,9 @@ final class AppState: ObservableObject { lastStatus = "No mode mapped for the current app." return } - present(await coordinator.handleApplyMode(mode)) + let outcome = await coordinator.handleApplyMode(mode) + if case .replaced(let text) = outcome { usage.recordAIAction(words: wordCount(text)) } + present(outcome) } private func present(_ outcome: ProcessOutcome) { @@ -145,6 +164,34 @@ final class AppState: ObservableObject { 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 { diff --git a/Sources/GrammaGem/App/MainWindow/AccountViews.swift b/Sources/GrammaGem/App/MainWindow/AccountViews.swift new file mode 100644 index 0000000..6304145 --- /dev/null +++ b/Sources/GrammaGem/App/MainWindow/AccountViews.swift @@ -0,0 +1,167 @@ +import SwiftUI + +// MARK: - General + +struct GeneralView: View { + @EnvironmentObject private var app: AppState + @EnvironmentObject private var prefs: Preferences + @EnvironmentObject private var permissions: Permissions + + var body: some View { + DetailScaffold(title: "General", subtitle: "Make GrammaGem feel like yours.") { + Card { + Toggle("Launch at login", isOn: $prefs.launchAtLogin) + Divider() + Toggle("Show live underlines as you type", isOn: $prefs.liveUnderlines) + Divider() + Toggle("Restore my clipboard after a fix", isOn: $prefs.restoreClipboard) + Divider() + Toggle("Play a sound on correction", isOn: $prefs.playSounds) + } + + Card { + Picker("Appearance", selection: $prefs.appearance) { + ForEach(Preferences.Appearance.allCases) { Text($0.label).tag($0) } + } + Picker("Menu bar", selection: $prefs.menuBarStyle) { + ForEach(Preferences.MenuBarStyle.allCases) { Text($0.label).tag($0) } + } + } + + Card { + Text("Permissions").font(.headline) + HStack { + Image(systemName: permissions.accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .foregroundStyle(permissions.accessibilityTrusted ? GG.emerald : .orange) + Text(permissions.accessibilityTrusted ? "Accessibility granted" : "Accessibility needed to fix text in apps") + Spacer() + if !permissions.accessibilityTrusted { + Button("Set up") { app.showOnboarding() } + } + } + } + } + } +} + +// MARK: - License + +struct LicenseView: View { + @EnvironmentObject private var app: AppState + @EnvironmentObject private var license: LicenseManager + @State private var keyField = "" + + private let plans: [(String, Int, String)] = [ + ("Solo", 39, "1 Mac"), + ("Personal", 59, "2 Macs"), + ("Studio", 89, "4 Macs"), + ("Startup License", 159, "10 Macs"), + ] + + var body: some View { + DetailScaffold(title: "License", subtitle: "One-time purchase. Lifetime updates.") { + Card { + HStack { + VStack(alignment: .leading) { + Text(license.tier.displayName).font(.title3.weight(.semibold)) + Text(license.isLicensed ? "Lifetime license · active" : "Free plan") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Image(systemName: license.isLicensed ? "checkmark.seal.fill" : "gift") + .font(.title).foregroundStyle(license.isLicensed ? GG.emerald : GG.gold) + } + } + + if license.isLicensed { + Card { + Text("Manage").font(.headline) + Button(role: .destructive) { + Task { await license.deactivateThisDevice() } + } label: { Text(license.isWorking ? "Working…" : "Deactivate this device") } + .disabled(license.isWorking) + Link("Open license portal", destination: AppConfig.modelPortalURL) + } + } else { + Card { + Text("Activate a license").font(.headline) + HStack { + TextField("Paste your license key", text: $keyField).textFieldStyle(.roundedBorder) + Button(license.isWorking ? "…" : "Activate") { + Task { await license.activate(key: keyField) } + } + .buttonStyle(.borderedProminent) + .disabled(license.isWorking || keyField.isEmpty) + } + } + Card { + Text("Plans").font(.headline) + ForEach(plans, id: \.0) { name, price, devices in + HStack { + Text(name).fontWeight(name == "Personal" ? .semibold : .regular) + if name == "Personal" { + Text("Popular").font(.caption2.weight(.semibold)) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(GG.gold.opacity(0.18), in: Capsule()).foregroundStyle(GG.gold) + } + Spacer() + Text(devices).font(.caption).foregroundStyle(.secondary) + Text("$\(price)").font(.body.weight(.semibold)).frame(width: 52, alignment: .trailing) + } + if name != plans.last?.0 { Divider() } + } + Link("Choose a plan", destination: AppConfig.websiteURL) + .buttonStyle(.borderedProminent) + } + } + + if let err = license.lastError { + Label(err, systemImage: "exclamationmark.triangle").font(.callout).foregroundStyle(.orange) + } + } + } +} + +// MARK: - About + +struct AboutView: View { + var body: some View { + DetailScaffold(title: "About", subtitle: nil) { + Card { + HStack(spacing: 14) { + Image(systemName: "pencil.and.scribble") + .font(.system(size: 34)).foregroundStyle(GG.emerald) + .frame(width: 56, height: 56) + .background(GG.emerald.opacity(0.12), in: RoundedRectangle(cornerRadius: 14)) + VStack(alignment: .leading, spacing: 2) { + Text("GrammaGem").font(.title2.bold()) + Text("Version \(AppConfig.appVersion)").foregroundStyle(.secondary) + Text("The private, on-device writing assistant for Mac.") + .font(.callout).foregroundStyle(.secondary) + } + Spacer() + } + } + + Card { + Link(destination: AppConfig.websiteURL) { Label("Website", systemImage: "globe") } + Divider() + Link(destination: AppConfig.websiteURL.appendingPathComponent("docs")) { Label("Documentation", systemImage: "book") } + Divider() + Link(destination: URL(string: "mailto:\(AppConfig.supportEmail)")!) { Label("Contact support", systemImage: "envelope") } + } + + Card { + Text("Open source").font(.headline) + Text("GrammaGem is open source under the MIT License — a privacy tool you can inspect.") + .font(.callout).foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 2) { + Text(AppConfig.copyright).font(.caption).foregroundStyle(.secondary) + Text(AppConfig.companyName + " · " + AppConfig.companyAddress) + .font(.caption2).foregroundStyle(.secondary) + } + } + } +} diff --git a/Sources/GrammaGem/App/MainWindow/DashboardView.swift b/Sources/GrammaGem/App/MainWindow/DashboardView.swift new file mode 100644 index 0000000..3b2206b --- /dev/null +++ b/Sources/GrammaGem/App/MainWindow/DashboardView.swift @@ -0,0 +1,132 @@ +import SwiftUI + +struct DashboardView: View { + @EnvironmentObject private var app: AppState + @EnvironmentObject private var license: LicenseManager + @EnvironmentObject private var gate: FeatureGate + @EnvironmentObject private var usage: UsageStats + @EnvironmentObject private var model: ModelManager + @EnvironmentObject private var permissions: Permissions + + var body: some View { + DetailScaffold( + title: "Welcome back", + subtitle: "Your private writing assistant, at a glance." + ) { + // Key metrics + LazyVGrid(columns: [GridItem(.adaptive(minimum: 158), spacing: 14)], spacing: 14) { + StatTile( + value: license.tier.displayName, + label: license.isLicensed ? "Lifetime plan" : "Free plan", + systemImage: "key.fill", tint: license.isLicensed ? GG.emerald : .secondary) + + if gate.entitlements.unlimitedAIActions { + StatTile(value: "∞", label: "AI actions today", systemImage: "infinity", tint: GG.emerald) + } else { + StatTile( + value: "\(gate.remainingAIActionsToday)/\(gate.entitlements.dailyAIActionCap)", + label: "AI actions left today", systemImage: "bolt.fill", tint: GG.gold) + } + + StatTile(value: "\(usage.data.totalCorrections)", label: "Corrections made", systemImage: "checkmark.seal.fill", tint: GG.emerald) + StatTile(value: timeSaved, label: "Time saved (est.)", systemImage: "clock.fill", tint: .primary) + } + + // Setup health + Card { + Text("Setup").font(.headline) + healthRow( + ok: permissions.accessibilityTrusted, + okText: "Accessibility granted", + badText: "Accessibility needed to fix text in apps", + action: permissions.accessibilityTrusted ? nil : ("Fix", { app.showOnboarding() })) + Divider() + healthRow( + ok: model.state == .ready, + okText: "On-device AI model ready", + badText: "AI model not downloaded (grammar still works)", + action: model.state == .ready ? nil : ("Set up", { app.showMainWindow(select: .model) })) + } + + // Activity + Card { + HStack { + Text("Activity").font(.headline) + Spacer() + Text("Last 7 days").font(.caption).foregroundStyle(.secondary) + } + WeeklyBars(points: usage.last7Days()) + .frame(height: 110) + Text("\(usage.data.totalWords) words polished overall — all on this Mac.") + .font(.caption).foregroundStyle(.secondary) + } + + // Quick actions + Card { + Text("Quick actions").font(.headline) + HStack { + Button { Task { await app.runFix() } } label: { + Label("Fix selection", systemImage: "checkmark.circle") + } + Button { app.showAsk() } label: { + Label("Ask GrammaGem", systemImage: "sparkles") + } + if !license.isLicensed { + Spacer() + Button { app.showMainWindow(select: .license) } label: { + Label("Upgrade", systemImage: "arrow.up.circle.fill") + } + .buttonStyle(.borderedProminent) + } + } + } + } + } + + private var timeSaved: String { + let m = usage.estimatedMinutesSaved + if m >= 60 { return "\(m / 60)h \(m % 60)m" } + return "\(m)m" + } + + @ViewBuilder + private func healthRow(ok: Bool, okText: String, badText: String, action: (String, () -> Void)?) -> some View { + HStack(spacing: 10) { + Image(systemName: ok ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .foregroundStyle(ok ? GG.emerald : .orange) + Text(ok ? okText : badText) + Spacer() + if let action { + Button(action.0, action: action.1) + } + } + } +} + +/// A tiny dependency-free weekly bar chart. +struct WeeklyBars: View { + let points: [(label: String, count: Int)] + + var body: some View { + let maxCount = max(points.map(\.count).max() ?? 0, 1) + HStack(alignment: .bottom, spacing: 10) { + ForEach(Array(points.enumerated()), id: \.offset) { _, point in + VStack(spacing: 6) { + Spacer(minLength: 0) + Text(point.count > 0 ? "\(point.count)" : "") + .font(.caption2).foregroundStyle(.secondary) + RoundedRectangle(cornerRadius: 5) + .fill(point.count > 0 ? GG.emerald : Color.secondary.opacity(0.18)) + .frame(height: barHeight(point.count, maxCount: maxCount)) + Text(point.label).font(.caption2).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } + } + } + + private func barHeight(_ count: Int, maxCount: Int) -> CGFloat { + let ratio = CGFloat(count) / CGFloat(maxCount) + return max(4, ratio * 78) + } +} diff --git a/Sources/GrammaGem/App/MainWindow/DataViews.swift b/Sources/GrammaGem/App/MainWindow/DataViews.swift new file mode 100644 index 0000000..de9c92d --- /dev/null +++ b/Sources/GrammaGem/App/MainWindow/DataViews.swift @@ -0,0 +1,118 @@ +import SwiftUI + +// MARK: - Dictionary + +struct DictionaryView: View { + @EnvironmentObject private var app: AppState + @EnvironmentObject private var gate: FeatureGate + @EnvironmentObject private var dictionary: PersonalDictionary + @State private var newWord = "" + @State private var message: String? + + var body: some View { + DetailScaffold( + title: "Personal Dictionary", + subtitle: "Words GrammaGem should never “correct” — names, brands, jargon." + ) { + Card { + HStack { + TextField("Add a word…", text: $newWord) + .textFieldStyle(.roundedBorder) + .onSubmit(add) + Button("Add", action: add) + .disabled(newWord.trimmingCharacters(in: .whitespaces).isEmpty) + } + if let message { + Label(message, systemImage: "lock").font(.caption).foregroundStyle(.orange) + } + let cap = gate.entitlements.dictionaryCap + Text(cap == Int.max + ? "\(dictionary.entries.count) entries · unlimited" + : "\(dictionary.entries.count) / \(cap) entries") + .font(.caption).foregroundStyle(.secondary) + } + + Card { + if dictionary.entries.isEmpty { + Text("No entries yet.").foregroundStyle(.secondary) + } else { + ForEach(dictionary.entries, id: \.self) { word in + HStack { + Text(word) + Spacer() + Button(role: .destructive) { app.removeDictionaryEntry(word) } label: { + Image(systemName: "trash") + }.buttonStyle(.borderless) + } + if word != dictionary.entries.last { Divider() } + } + } + } + } + } + + private func add() { + switch app.addDictionaryEntry(newWord) { + case .allowed: newWord = ""; message = nil + case .denied(let m): message = m + } + } +} + +// MARK: - Snippets + +struct SnippetsView: View { + @EnvironmentObject private var app: AppState + @EnvironmentObject private var gate: FeatureGate + @EnvironmentObject private var snippets: SnippetStore + @State private var trigger = "" + @State private var expansion = "" + + var body: some View { + DetailScaffold( + title: "Snippets", + subtitle: "Type a short trigger, expand it into anything. Stored only on this Mac." + ) { + if gate.snippetsEnabled { + Card { + HStack(alignment: .top) { + TextField("Trigger (e.g. ;addr)", text: $trigger).textFieldStyle(.roundedBorder).frame(width: 160) + TextField("Expansion…", text: $expansion, axis: .vertical) + .lineLimit(1...4).textFieldStyle(.roundedBorder) + Button("Add") { + snippets.add(trigger: trigger, expansion: expansion) + trigger = ""; expansion = "" + } + .disabled(trigger.trimmingCharacters(in: .whitespaces).isEmpty + || expansion.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + Card { + if snippets.snippets.isEmpty { + Text("No snippets yet.").foregroundStyle(.secondary) + } else { + ForEach(snippets.snippets) { s in + HStack(alignment: .top) { + Text(s.trigger).font(.callout.monospaced()).foregroundStyle(GG.emerald) + Text(s.expansion).foregroundStyle(.secondary).lineLimit(2) + Spacer() + Button(role: .destructive) { snippets.remove(s) } label: { + Image(systemName: "trash") + }.buttonStyle(.borderless) + } + if s.id != snippets.snippets.last?.id { Divider() } + } + } + } + } else { + Card { + HStack { Text("Text expansion").font(.headline); UpgradeBadge() } + Text("Snippets are part of the lifetime license. Save your common replies, addresses, and boilerplate, and expand them with a keystroke anywhere.") + .foregroundStyle(.secondary) + Button("Unlock snippets") { app.showMainWindow(select: .license) } + .buttonStyle(.borderedProminent) + } + } + } + } +} diff --git a/Sources/GrammaGem/App/MainWindow/DevicesView.swift b/Sources/GrammaGem/App/MainWindow/DevicesView.swift new file mode 100644 index 0000000..d2147f9 --- /dev/null +++ b/Sources/GrammaGem/App/MainWindow/DevicesView.swift @@ -0,0 +1,91 @@ +import SwiftUI + +struct DevicesView: View { + @EnvironmentObject private var app: AppState + @EnvironmentObject private var license: LicenseManager + + var body: some View { + DetailScaffold( + title: "Devices", + subtitle: "Manage the Macs your license is active on." + ) { + if let record = license.record { + usageSummary(record) + thisDevice(record) + otherDevices(record) + } else { + Card { + Label("No active license on this Mac", systemImage: "key.slash") + .font(.headline) + Text("Activate a license to manage devices. The free plan runs on one Mac.") + .foregroundStyle(.secondary) + Button("Enter a license key") { app.showMainWindow(select: .license) } + .buttonStyle(.borderedProminent) + } + } + } + } + + private func usageSummary(_ r: LicenseRecord) -> some View { + Card { + HStack(alignment: .firstTextBaseline) { + Text("\(max(r.activationUsage, 1)) of \(r.activationLimit)") + .font(.system(.title, design: .rounded).weight(.semibold)) + Text("devices in use").foregroundStyle(.secondary) + Spacer() + Text(license.tier.displayName).font(.subheadline).foregroundStyle(GG.emerald) + } + ProgressView(value: Double(max(r.activationUsage, 1)), total: Double(max(r.activationLimit, 1))) + .tint(GG.emerald) + Text("Your \(license.tier.displayName) plan covers up to \(r.activationLimit) Mac\(r.activationLimit == 1 ? "" : "s").") + .font(.caption).foregroundStyle(.secondary) + } + } + + private func thisDevice(_ r: LicenseRecord) -> some View { + Card { + HStack(spacing: 12) { + Image(systemName: "laptopcomputer") + .font(.title2).foregroundStyle(GG.emerald) + .frame(width: 34) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text(r.deviceName).font(.headline) + Text("This Mac") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 7).padding(.vertical, 2) + .background(GG.emerald.opacity(0.15), in: Capsule()) + .foregroundStyle(GG.emerald) + } + Text("Activated \(r.activatedAt.formatted(date: .abbreviated, time: .omitted)) · validated \(r.lastValidated.formatted(.relative(presentation: .named)))") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button(role: .destructive) { + Task { await app.license.deactivateThisDevice() } + } label: { + if license.isWorking { ProgressView().controlSize(.small) } + else { Text("Deactivate") } + } + .disabled(license.isWorking) + } + if let err = license.lastError { + Label(err, systemImage: "exclamationmark.triangle").font(.caption).foregroundStyle(.orange) + } + } + } + + private func otherDevices(_ r: LicenseRecord) -> some View { + Card { + Text("Other devices").font(.headline) + Text("Deactivating a Mac here frees a slot instantly. To rename or remove a Mac you no longer have access to, open the license portal.") + .font(.callout).foregroundStyle(.secondary) + HStack { + Link("Open license portal", destination: AppConfig.modelPortalURL) + Spacer() + Text("Add a Mac: install GrammaGem there and paste the same key.") + .font(.caption).foregroundStyle(.secondary) + } + } + } +} diff --git a/Sources/GrammaGem/App/MainWindow/MainWindowView.swift b/Sources/GrammaGem/App/MainWindow/MainWindowView.swift new file mode 100644 index 0000000..78937cb --- /dev/null +++ b/Sources/GrammaGem/App/MainWindow/MainWindowView.swift @@ -0,0 +1,179 @@ +import SwiftUI + +/// Brand accents for the app UI (mirrors the website CI). +enum GG { + static let emerald = Color(red: 0x0E / 255, green: 0x7C / 255, blue: 0x5A / 255) + static let gold = Color(red: 0xC9 / 255, green: 0xA2 / 255, blue: 0x4B / 255) +} + +/// Sections of the management window. +enum MainSection: String, CaseIterable, Identifiable { + case dashboard, devices, license + case modes, dictionary, snippets + case shortcuts, model, privacy, general + case about + + var id: String { rawValue } + + var title: String { + switch self { + case .dashboard: return "Dashboard" + case .devices: return "Devices" + case .license: return "License" + case .modes: return "Writing Modes" + case .dictionary: return "Dictionary" + case .snippets: return "Snippets" + case .shortcuts: return "Shortcuts" + case .model: return "AI Model" + case .privacy: return "Privacy" + case .general: return "General" + case .about: return "About" + } + } + + var systemImage: String { + switch self { + case .dashboard: return "square.grid.2x2" + case .devices: return "laptopcomputer" + case .license: return "key" + case .modes: return "slider.horizontal.3" + case .dictionary: return "character.book.closed" + case .snippets: return "text.badge.plus" + case .shortcuts: return "command" + case .model: return "cpu" + case .privacy: return "lock.shield" + case .general: return "gearshape" + case .about: return "info.circle" + } + } + + /// Sidebar groups, in order. + static let groups: [(String, [MainSection])] = [ + ("Overview", [.dashboard]), + ("Account", [.devices, .license]), + ("Writing", [.modes, .dictionary, .snippets]), + ("App", [.shortcuts, .model, .privacy, .general]), + ("", [.about]), + ] +} + +/// The full GrammaGem management window: a clean sidebar + detail layout. +struct MainWindowView: View { + @State private var selection: MainSection? + + init(initialSection: MainSection = .dashboard) { + _selection = State(initialValue: initialSection) + } + + var body: some View { + NavigationSplitView { + List(selection: $selection) { + ForEach(MainSection.groups, id: \.0) { group, items in + Section(group) { + ForEach(items) { section in + Label(section.title, systemImage: section.systemImage) + .tag(section) + } + } + } + } + .navigationSplitViewColumnWidth(min: 198, ideal: 216, max: 260) + .listStyle(.sidebar) + } detail: { + detail(for: selection ?? .dashboard) + } + .tint(GG.emerald) + .frame(minWidth: 820, minHeight: 560) + } + + @ViewBuilder + private func detail(for section: MainSection) -> some View { + switch section { + case .dashboard: DashboardView() + case .devices: DevicesView() + case .license: LicenseView() + case .modes: WritingModesView() + case .dictionary: DictionaryView() + case .snippets: SnippetsView() + case .shortcuts: ShortcutsView() + case .model: ModelView() + case .privacy: PrivacyView() + case .general: GeneralView() + case .about: AboutView() + } + } +} + +// MARK: - Reusable layout + +/// A scrollable detail page with a title/subtitle header and a max content width. +struct DetailScaffold: View { + let title: String + var subtitle: String? = nil + @ViewBuilder var content: Content + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.largeTitle.bold()) + if let subtitle { + Text(subtitle).font(.body).foregroundStyle(.secondary) + } + } + content + } + .frame(maxWidth: 720, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(28) + } + } +} + +/// A soft card container used throughout the window. +struct Card: View { + var padding: CGFloat = 18 + @ViewBuilder var content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 12) { content } + .padding(padding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.primary.opacity(0.07), lineWidth: 1) + ) + } +} + +/// A small labelled metric tile. +struct StatTile: View { + let value: String + let label: String + var systemImage: String? = nil + var tint: Color = .primary + + var body: some View { + Card { + HStack(spacing: 8) { + if let systemImage { + Image(systemName: systemImage).foregroundStyle(tint) + } + Text(value).font(.system(.title, design: .rounded).weight(.semibold)).foregroundStyle(tint) + } + Text(label).font(.caption).foregroundStyle(.secondary) + } + } +} + +/// A locked-feature badge used to surface upgrade triggers cleanly. +struct UpgradeBadge: View { + var body: some View { + Label("Lifetime", systemImage: "lock.fill") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 7).padding(.vertical, 3) + .background(GG.gold.opacity(0.18), in: Capsule()) + .foregroundStyle(GG.gold) + } +} diff --git a/Sources/GrammaGem/App/MainWindow/SystemViews.swift b/Sources/GrammaGem/App/MainWindow/SystemViews.swift new file mode 100644 index 0000000..a3f9707 --- /dev/null +++ b/Sources/GrammaGem/App/MainWindow/SystemViews.swift @@ -0,0 +1,139 @@ +import SwiftUI + +// MARK: - Shortcuts + +struct ShortcutsView: View { + var body: some View { + DetailScaffold( + title: "Shortcuts", + subtitle: "Global hotkeys that work in every app." + ) { + Card { + shortcutRow(name: "Fix selection", keys: ["⌘", ";"], detail: "Grammar + clarity, in place") + Divider() + shortcutRow(name: "Ask GrammaGem", keys: ["⌘", "'"], detail: "Rewrite with an instruction") + } + Card { + Label("Custom shortcuts", systemImage: "wand.and.stars") + .font(.headline) + Text("A built-in recorder for fully customizable shortcuts is on the roadmap. For now the defaults above are active system-wide.") + .font(.callout).foregroundStyle(.secondary) + } + } + } + + private func shortcutRow(name: String, keys: [String], detail: String) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(name).fontWeight(.medium) + Text(detail).font(.caption).foregroundStyle(.secondary) + } + Spacer() + HStack(spacing: 4) { + ForEach(keys, id: \.self) { k in + Text(k) + .font(.system(.body, design: .monospaced)) + .padding(.horizontal, 8).padding(.vertical, 4) + .background(Color(nsColor: .controlColor), in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.12))) + } + } + } + } +} + +// MARK: - AI Model + +struct ModelView: View { + @EnvironmentObject private var app: AppState + @EnvironmentObject private var gate: FeatureGate + @EnvironmentObject private var model: ModelManager + + var body: some View { + DetailScaffold( + title: "AI Model", + subtitle: "The local model that powers rewrites, tone, and Ask. Runs entirely on your Mac." + ) { + Card { + Picker("Model", selection: $model.selectedRepo) { + Text("Qwen2.5-3B · balanced (default)").tag(AppConfig.Model.defaultRepo) + Text("Qwen2.5-7B · higher quality").tag(AppConfig.Model.largeRepo) + } + .disabled(!gate.entitlements.canUseLargeModel) + + if !gate.entitlements.canUseLargeModel { + HStack { Image(systemName: "lock.fill").foregroundStyle(GG.gold); Text("The larger model is available on the lifetime license.").font(.caption).foregroundStyle(.secondary) } + } + Divider() + ModelDownloadControls().environmentObject(app) + } + + Card { + Label("Private by default", systemImage: "lock.shield") + .font(.headline) + Text("The model is downloaded once from Hugging Face and then runs offline. It is never bundled in the app, and it never sends your text anywhere.") + .font(.callout).foregroundStyle(.secondary) + } + } + } +} + +// MARK: - Privacy + +struct PrivacyView: View { + @EnvironmentObject private var usage: UsageStats + @State private var confirmReset = false + + var body: some View { + DetailScaffold( + title: "Privacy", + subtitle: "What touches the network — and what never does." + ) { + Card { + HStack(spacing: 10) { + Image(systemName: "checkmark.shield.fill").font(.title2).foregroundStyle(GG.emerald) + VStack(alignment: .leading, spacing: 2) { + Text("0 bytes of your writing leave this Mac").font(.headline) + Text("Every correction and rewrite runs locally. No telemetry is collected.") + .font(.callout).foregroundStyle(.secondary) + } + } + } + + Card { + Text("The only times GrammaGem uses the network").font(.headline) + networkRow("One-time AI model download", "Hugging Face, on first run") + Divider() + networkRow("License activation & checks", "To enforce your device cap") + Divider() + networkRow("App updates", "Signed update checks") + Text("None of these ever include the text you write.") + .font(.caption).foregroundStyle(.secondary) + } + + Card { + Text("Local data").font(.headline) + Text("Your dictionary, snippets, modes, and usage counts live only on this Mac.") + .font(.callout).foregroundStyle(.secondary) + Button(role: .destructive) { confirmReset = true } label: { + Label("Reset usage statistics", systemImage: "arrow.counterclockwise") + } + .confirmationDialog("Reset all local usage statistics?", isPresented: $confirmReset) { + Button("Reset", role: .destructive) { usage.reset() } + Button("Cancel", role: .cancel) {} + } + } + } + } + + private func networkRow(_ title: String, _ detail: String) -> some View { + HStack(spacing: 10) { + Image(systemName: "arrow.up.forward.circle").foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 1) { + Text(title) + Text(detail).font(.caption).foregroundStyle(.secondary) + } + Spacer() + } + } +} diff --git a/Sources/GrammaGem/App/MainWindow/WritingModesView.swift b/Sources/GrammaGem/App/MainWindow/WritingModesView.swift new file mode 100644 index 0000000..e25b9f2 --- /dev/null +++ b/Sources/GrammaGem/App/MainWindow/WritingModesView.swift @@ -0,0 +1,131 @@ +import SwiftUI + +struct WritingModesView: View { + @EnvironmentObject private var app: AppState + @EnvironmentObject private var gate: FeatureGate + @EnvironmentObject private var customModes: CustomModesStore + + @State private var newName = "" + @State private var newPrompt = "" + @State private var newBundleID = "" + @State private var newMappedMode = ModeRegistry.polish.id + + private var paid: Bool { gate.entitlements.allModes } + + var body: some View { + DetailScaffold( + title: "Writing Modes", + subtitle: "Tune the tone for every context — or let GrammaGem pick it for you." + ) { + builtInCard + customCard + appAwareCard + } + } + + private var builtInCard: some View { + Card { + Text("Built-in modes").font(.headline) + ForEach(ModeRegistry.all) { mode in + HStack(alignment: .top, spacing: 10) { + Image(systemName: mode.isPaid ? "lock.fill" : "checkmark.circle.fill") + .foregroundStyle(mode.isPaid && !paid ? Color.secondary : GG.emerald) + .frame(width: 18) + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(mode.name).fontWeight(.medium) + if mode.isPaid && !paid { UpgradeBadge() } + } + Text(mode.autoFormat ?? "").font(.caption).foregroundStyle(.secondary) + } + Spacer() + } + if mode.id != ModeRegistry.all.last?.id { Divider() } + } + } + } + + private var customCard: some View { + Card { + HStack { Text("Custom modes").font(.headline); if !paid { UpgradeBadge() } } + if customModes.customModes.isEmpty { + Text("Create your own modes with your own instructions — one per context you write in.") + .font(.callout).foregroundStyle(.secondary) + } else { + ForEach(customModes.customModes) { mode in + HStack { + VStack(alignment: .leading) { + Text(mode.name).fontWeight(.medium) + Text(mode.systemPrompt).font(.caption).foregroundStyle(.secondary).lineLimit(2) + } + Spacer() + Button(role: .destructive) { customModes.removeCustom(id: mode.id) } label: { + Image(systemName: "trash") + }.buttonStyle(.borderless) + } + Divider() + } + } + + if paid { + TextField("Mode name (e.g. “Newsletter”)", text: $newName).textFieldStyle(.roundedBorder) + TextField("Instruction the model should follow…", text: $newPrompt, axis: .vertical) + .lineLimit(2...4).textFieldStyle(.roundedBorder) + HStack { + Spacer() + Button("Add mode") { + customModes.addCustom(name: newName, prompt: newPrompt) + newName = ""; newPrompt = "" + } + .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } else { + Button("Unlock custom modes") { app.showMainWindow(select: .license) } + } + } + } + + private var appAwareCard: some View { + Card { + HStack { Text("App-aware switching").font(.headline); if !paid { UpgradeBadge() } } + Text("Map an app to a Mode and GrammaGem applies it automatically when that app is in front.") + .font(.callout).foregroundStyle(.secondary) + + if !customModes.appOverrides.isEmpty { + ForEach(customModes.appOverrides.sorted(by: { $0.key < $1.key }), id: \.key) { bundle, modeID in + HStack { + Text(bundle).font(.callout.monospaced()) + Image(systemName: "arrow.right").foregroundStyle(.secondary) + Text(modeName(modeID)).fontWeight(.medium) + Spacer() + Button(role: .destructive) { customModes.clearOverride(bundleID: bundle) } label: { + Image(systemName: "trash") + }.buttonStyle(.borderless) + } + Divider() + } + } + + if paid { + HStack { + TextField("App bundle id (e.g. com.apple.mail)", text: $newBundleID) + .textFieldStyle(.roundedBorder) + Picker("", selection: $newMappedMode) { + ForEach(customModes.allModes()) { m in Text(m.name).tag(m.id) } + }.labelsHidden().frame(width: 160) + Button("Map") { + customModes.setOverride(bundleID: newBundleID, modeID: newMappedMode) + newBundleID = "" + } + .disabled(newBundleID.trimmingCharacters(in: .whitespaces).isEmpty) + } + } else { + Button("Unlock app-aware switching") { app.showMainWindow(select: .license) } + } + } + } + + private func modeName(_ id: String) -> String { + customModes.allModes().first { $0.id == id }?.name ?? id + } +} diff --git a/Sources/GrammaGem/App/MenuBarContent.swift b/Sources/GrammaGem/App/MenuBarContent.swift index 4dabbad..62ca8ce 100644 --- a/Sources/GrammaGem/App/MenuBarContent.swift +++ b/Sources/GrammaGem/App/MenuBarContent.swift @@ -25,6 +25,15 @@ struct MenuBarContent: View { Divider() + actionRow(title: "Open GrammaGem…", shortcut: nil) { + app.showMainWindow() + } + actionRow(title: "Manage devices…", shortcut: nil) { + app.showMainWindow(select: .devices) + } + + Divider() + usageRow Divider() diff --git a/Sources/GrammaGem/Core/AppConfig.swift b/Sources/GrammaGem/Core/AppConfig.swift index 06429d2..a1016a2 100644 --- a/Sources/GrammaGem/Core/AppConfig.swift +++ b/Sources/GrammaGem/Core/AppConfig.swift @@ -6,9 +6,16 @@ import Foundation /// TODO(real-integration): replace every `TODO_*` value with your real IDs. enum AppConfig { static let appName = "GrammaGem" - static let bundleIdentifier = "com.grammagem.app" // TODO(real-integration) + static let bundleIdentifier = "com.foundergem.grammagem" // TODO(real-integration) static let supportEmail = "support@grammagem.app" // TODO(real-integration) static let modelPortalURL = URL(string: "https://grammagem.app/license")! // TODO + static let websiteURL = URL(string: "https://grammagem.app")! + static let appVersion = "0.1.0" + + // Legal entity (shown in About / legal). + static let companyName = "FounderGem LLC" + static let companyAddress = "1309 Coffeen Avenue STE 1200, Sheridan, WY 82801, USA" + static let copyright = "© 2026 FounderGem LLC" /// Lemon Squeezy License API (app talks to it directly — no server of ours). enum LemonSqueezy { diff --git a/Sources/GrammaGem/Licensing/DeviceFingerprint.swift b/Sources/GrammaGem/Licensing/DeviceFingerprint.swift index 67f7c2d..be5ae2b 100644 --- a/Sources/GrammaGem/Licensing/DeviceFingerprint.swift +++ b/Sources/GrammaGem/Licensing/DeviceFingerprint.swift @@ -32,6 +32,11 @@ enum DeviceFingerprint { return cf.takeRetainedValue() as? String } + /// A friendly, human-readable name for this Mac (for the Devices screen). + static func deviceName() -> String { + Host.current().localizedName ?? ProcessInfo.processInfo.hostName + } + private static func fallbackID() -> String { let key = "GrammaGem.fallbackDeviceID" if let existing = UserDefaults.standard.string(forKey: key) { return existing } diff --git a/Sources/GrammaGem/Licensing/LemonSqueezyClient.swift b/Sources/GrammaGem/Licensing/LemonSqueezyClient.swift index 6ae024e..0c3da19 100644 --- a/Sources/GrammaGem/Licensing/LemonSqueezyClient.swift +++ b/Sources/GrammaGem/Licensing/LemonSqueezyClient.swift @@ -13,7 +13,7 @@ final class LemonSqueezyClient { // MARK: - Endpoints - func activate(key: String, instanceName: String) async throws -> (instanceID: String, tier: Tier) { + func activate(key: String, instanceName: String) async throws -> ActivationResult { let res: LSActivateResponse = try await post( "licenses/activate", params: ["license_key": key, "instance_name": instanceName]) @@ -23,17 +23,23 @@ final class LemonSqueezyClient { throw LicenseError.serverMessage("Activation failed.") } let tier = try verifyAndResolveTier(meta: res.meta, licenseKey: res.license_key) - return (instance.id, tier) + return ActivationResult( + instanceID: instance.id, tier: tier, + usage: res.license_key?.activation_usage ?? 1, + limit: res.license_key?.activation_limit ?? tier.deviceCap) } - func validate(key: String, instanceID: String?) async throws -> (valid: Bool, tier: Tier) { + func validate(key: String, instanceID: String?) async throws -> ValidationResult { var params = ["license_key": key] if let instanceID { params["instance_id"] = instanceID } let res: LSValidateResponse = try await post("licenses/validate", params: params) if let err = res.error { throw mapServerError(err) } let tier = try verifyAndResolveTier(meta: res.meta, licenseKey: res.license_key) - return (res.valid, tier) + return ValidationResult( + valid: res.valid, tier: tier, + usage: res.license_key?.activation_usage ?? 0, + limit: res.license_key?.activation_limit ?? tier.deviceCap) } @discardableResult diff --git a/Sources/GrammaGem/Licensing/LicenseManager.swift b/Sources/GrammaGem/Licensing/LicenseManager.swift index cbaec6c..8508d02 100644 --- a/Sources/GrammaGem/Licensing/LicenseManager.swift +++ b/Sources/GrammaGem/Licensing/LicenseManager.swift @@ -44,13 +44,15 @@ final class LicenseManager: ObservableObject { do { let instanceName = DeviceFingerprint.stableID() - let (instanceID, tier) = try await client.activate(key: key, instanceName: instanceName) + let result = try await client.activate(key: key, instanceName: instanceName) let rec = LicenseRecord( - key: key, instanceID: instanceID, tier: tier, - lastValidated: Date(), activatedDevices: tier.deviceCap) + key: key, instanceID: result.instanceID, tier: result.tier, + lastValidated: Date(), activatedAt: Date(), + deviceName: DeviceFingerprint.deviceName(), + activationUsage: result.usage, activationLimit: result.limit) Keychain.setObject(rec, account: account) record = rec - Log.licensing.info("Activated tier \(tier.rawValue, privacy: .public)") + Log.licensing.info("Activated tier \(result.tier.rawValue, privacy: .public)") } catch { lastError = error.localizedDescription } @@ -63,10 +65,12 @@ final class LicenseManager: ObservableObject { guard force || Date().timeIntervalSince(rec.lastValidated) > interval else { return } do { - let (valid, tier) = try await client.validate(key: rec.key, instanceID: rec.instanceID) - if valid { + let result = try await client.validate(key: rec.key, instanceID: rec.instanceID) + if result.valid { rec.lastValidated = Date() - rec.tier = tier + rec.tier = result.tier + rec.activationUsage = result.usage + rec.activationLimit = result.limit Keychain.setObject(rec, account: account) record = rec } else { diff --git a/Sources/GrammaGem/Licensing/LicenseModels.swift b/Sources/GrammaGem/Licensing/LicenseModels.swift index 95146aa..a9196a5 100644 --- a/Sources/GrammaGem/Licensing/LicenseModels.swift +++ b/Sources/GrammaGem/Licensing/LicenseModels.swift @@ -6,7 +6,26 @@ struct LicenseRecord: Codable, Equatable { var instanceID: String // Lemon Squeezy activation instance id var tier: Tier var lastValidated: Date - var activatedDevices: Int + var activatedAt: Date // when this Mac was first activated + var deviceName: String // friendly name of this Mac + var activationUsage: Int // devices currently using the license + var activationLimit: Int // device cap for the tier +} + +/// Result of an activate call. +struct ActivationResult { + let instanceID: String + let tier: Tier + let usage: Int + let limit: Int +} + +/// Result of a validate call. +struct ValidationResult { + let valid: Bool + let tier: Tier + let usage: Int + let limit: Int } // MARK: - Lemon Squeezy API response shapes (subset we rely on) diff --git a/Sources/GrammaGem/Preferences/Preferences.swift b/Sources/GrammaGem/Preferences/Preferences.swift new file mode 100644 index 0000000..9bb7700 --- /dev/null +++ b/Sources/GrammaGem/Preferences/Preferences.swift @@ -0,0 +1,55 @@ +import Foundation +import AppKit +import ServiceManagement + +/// User-facing app preferences, persisted to UserDefaults. Kept separate from +/// licensing/entitlements so the General settings stay simple. +@MainActor +final class Preferences: ObservableObject { + enum Appearance: String, CaseIterable, Identifiable { + case system, light, dark + var id: String { rawValue } + var label: String { rawValue.capitalized } + } + + enum MenuBarStyle: String, CaseIterable, Identifiable { + case icon, monogram + var id: String { rawValue } + var label: String { self == .icon ? "Icon" : "Monogram" } + } + + private let d = UserDefaults.standard + + @Published var launchAtLogin: Bool { didSet { applyLaunchAtLogin() } } + @Published var playSounds: Bool { didSet { d.set(playSounds, forKey: "pref.playSounds") } } + @Published var liveUnderlines: Bool { didSet { d.set(liveUnderlines, forKey: "pref.liveUnderlines") } } + @Published var restoreClipboard: Bool { didSet { d.set(restoreClipboard, forKey: "pref.restoreClipboard") } } + @Published var appearance: Appearance { didSet { d.set(appearance.rawValue, forKey: "pref.appearance"); applyAppearance() } } + @Published var menuBarStyle: MenuBarStyle { didSet { d.set(menuBarStyle.rawValue, forKey: "pref.menuBarStyle") } } + + init() { + launchAtLogin = SMAppService.mainApp.status == .enabled + playSounds = d.object(forKey: "pref.playSounds") as? Bool ?? true + liveUnderlines = d.object(forKey: "pref.liveUnderlines") as? Bool ?? true + restoreClipboard = d.object(forKey: "pref.restoreClipboard") as? Bool ?? true + appearance = Appearance(rawValue: d.string(forKey: "pref.appearance") ?? "") ?? .system + menuBarStyle = MenuBarStyle(rawValue: d.string(forKey: "pref.menuBarStyle") ?? "") ?? .icon + } + + private func applyLaunchAtLogin() { + do { + if launchAtLogin { try SMAppService.mainApp.register() } + else { try SMAppService.mainApp.unregister() } + } catch { + Log.app.error("Launch-at-login change failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func applyAppearance() { + switch appearance { + case .system: NSApp.appearance = nil + case .light: NSApp.appearance = NSAppearance(named: .aqua) + case .dark: NSApp.appearance = NSAppearance(named: .darkAqua) + } + } +} diff --git a/Sources/GrammaGem/Stats/UsageStats.swift b/Sources/GrammaGem/Stats/UsageStats.swift new file mode 100644 index 0000000..3495f35 --- /dev/null +++ b/Sources/GrammaGem/Stats/UsageStats.swift @@ -0,0 +1,90 @@ +import Foundation + +/// Privacy-friendly, fully-local usage insights. Counts *how often* features are +/// used — never *what* was written. Stored only on this Mac. +@MainActor +final class UsageStats: ObservableObject { + struct Snapshot: Codable, Equatable { + var totalCorrections = 0 + var totalAIActions = 0 + var totalWords = 0 + var perDay: [String: Int] = [:] // "yyyy-MM-dd" -> actions that day + } + + @Published private(set) var data: Snapshot + + private let key = "GrammaGem.usageStats" + private let d = UserDefaults.standard + + private static let fmt: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = .current + return f + }() + + init() { + if let raw = d.data(forKey: key), + let decoded = try? JSONDecoder().decode(Snapshot.self, from: raw) { + data = decoded + } else { + data = Snapshot() + } + } + + func recordCorrection(words: Int) { + data.totalCorrections += 1 + data.totalWords += max(0, words) + bumpToday() + persist() + } + + func recordAIAction(words: Int) { + data.totalAIActions += 1 + data.totalWords += max(0, words) + bumpToday() + persist() + } + + /// A playful, clearly-estimated "time saved" figure (local only). + var estimatedMinutesSaved: Int { + (data.totalCorrections * 8 + data.totalAIActions * 45) / 60 + } + + /// Counts for the last 7 calendar days, oldest → newest, for a small chart. + func last7Days() -> [(label: String, count: Int)] { + let cal = Calendar.current + let today = Date() + return (0..<7).reversed().map { offset in + let day = cal.date(byAdding: .day, value: -offset, to: today) ?? today + let key = Self.fmt.string(from: day) + let short = day.formatted(.dateTime.weekday(.narrow)) + return (label: short, count: data.perDay[key] ?? 0) + } + } + + func reset() { + data = Snapshot() + persist() + } + + private func bumpToday() { + let k = Self.fmt.string(from: Date()) + data.perDay[k, default: 0] += 1 + // Keep only ~60 days of history. + if data.perDay.count > 70 { + let keep = Set((0..<60).compactMap { off -> String? in + guard let day = Calendar.current.date(byAdding: .day, value: -off, to: Date()) else { return nil } + return Self.fmt.string(from: day) + }) + data.perDay = data.perDay.filter { keep.contains($0.key) } + } + } + + private func persist() { + if let raw = try? JSONEncoder().encode(data) { + d.set(raw, forKey: key) + } + } +} diff --git a/Sources/GrammaGem/System/SnippetStore.swift b/Sources/GrammaGem/System/SnippetStore.swift new file mode 100644 index 0000000..4d17914 --- /dev/null +++ b/Sources/GrammaGem/System/SnippetStore.swift @@ -0,0 +1,51 @@ +import Foundation + +/// A text-expansion snippet: type a short trigger, get a longer expansion. +struct Snippet: Codable, Identifiable, Equatable { + var id = UUID() + var trigger: String + var expansion: String +} + +/// Local store for text-expansion snippets (a paid feature). Persisted to disk; +/// snippet contents never leave the device. +@MainActor +final class SnippetStore: ObservableObject { + @Published private(set) var snippets: [Snippet] + + private let key = "GrammaGem.snippets" + private let d = UserDefaults.standard + + init() { + if let raw = d.data(forKey: key), + let decoded = try? JSONDecoder().decode([Snippet].self, from: raw) { + snippets = decoded + } else { + snippets = [] + } + } + + func add(trigger: String, expansion: String) { + let t = trigger.trimmingCharacters(in: .whitespaces) + let e = expansion.trimmingCharacters(in: .whitespacesAndNewlines) + guard !t.isEmpty, !e.isEmpty else { return } + snippets.append(Snippet(trigger: t, expansion: e)) + persist() + } + + func remove(_ snippet: Snippet) { + snippets.removeAll { $0.id == snippet.id } + persist() + } + + /// Resolve a trigger to its expansion (used by the snippet hotkey path). + func expansion(for trigger: String) -> String? { + snippets.first { $0.trigger == trigger }?.expansion + } + + private func persist() { + if let raw = try? JSONEncoder().encode(snippets) { + d.set(raw, forKey: key) + } + } +}