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>
This commit is contained in:
BigMarc 2026-06-16 14:28:23 +02:00
parent f5faa27f88
commit a7bf45b466
21 changed files with 1417 additions and 119 deletions

View File

@ -36,7 +36,7 @@
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>© 2026 GrammaGem. Built in public.</string> <string>© 2026 FounderGem LLC. Built in public.</string>
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string>NSApplication</string> <string>NSApplication</string>
</dict> </dict>

View File

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 GrammaGem Copyright (c) 2026 FounderGem LLC
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

179
README.md
View File

@ -1,125 +1,102 @@
# GrammaGem (macOS app) <div align="center">
A private, **on-device** writing assistant for macOS — a one-time-purchase # ✒️ GrammaGem
replacement for Grammarly. Fix grammar, sharpen tone, and rewrite anything in
**any app** via a global hotkey. Your words never leave your Mac.
Built in public. The grammar core is open-source; the repo is permissively ### Write it once. Get it right.
licensed. We sell the **signed, notarized, auto-updating build + a multi-device
license + support** — not code secrecy. **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)
</div>
--- ---
## Architecture (two layers) ## Stop renting your grammar checker.
``` 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**.
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)
```
Network is touched **only** for: first-run model download (Hugging Face), 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**.
license activate/validate (Lemon Squeezy), and update checks (Sparkle/GitHub
Releases). **Writing never requires internet. No telemetry.**
## 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 | | Grammarly | **GrammaGem** |
cd mac |---|:---:|:---:|
swift build # compiles the app (Apple frameworks only) | **Price** | ~$144 / year, forever | **Pay once** |
swift test # runs the unit tests | **Your words** | Sent to the cloud | **Never leave your Mac** |
swift run GrammaGem # runs as a menu-bar (accessory) app | **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 → ## What you get
Privacy & Security → Accessibility). Without it the app degrades gracefully to a
manual paste-in window. Default hotkeys: **⌘;** fix selection, **⌘'** Ask.
> Prefer Xcode? Open `Package.swift` directly in Xcode 26 (File → Open). For a - ⚡ **Instant corrections** — grammar, spelling, punctuation, and clarity, fixed the moment you ask.
> production app target, point it at `AppSupport/Info.plist` + entitlements. - 🔒 **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, ## Buy once. Write forever.
with the heavyweight native integrations behind clean protocol seams so it
compiles with **zero external dependencies**. Search `TODO(real-integration)`.
| Area | Status | No subscription. No per-seat fees. One payment, lifetime updates.
|------|--------|
| 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) |
### Before shipping | Plan | Price | Devices |
1. Set real IDs in [`Core/AppConfig.swift`](Sources/GrammaGem/Core/AppConfig.swift): |------|:-----:|:-------:|
Lemon Squeezy `expectedStoreID` / `expectedProductID` / per-tier `variantID` | **Solo** | $39 | 1 Mac |
(set each variant's **activation limit** to the device cap: Solo 1 / Personal 2 / Studio 4), | **Personal** ⭐ | $59 | 2 Macs |
`bundleIdentifier`, `supportEmail`, model portal URL. | **Studio** | $89 | 4 Macs |
2. Replace the Harper stub with the FFI (`harper-ffi/README.md`). | **Startup License** | $159 | 10 Macs |
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).
## 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 <div align="center">
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 | </div>
|------|------|
| 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 ## FAQ
signed binary + multi-device license, not the source.
**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.
<div align="center">
**[grammagem.app](https://grammagem.app)**
© 2026 FounderGem LLC · 1309 Coffeen Avenue STE 1200, Sheridan, WY 82801, USA
</div>

View File

@ -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)
}
}

View File

@ -20,6 +20,10 @@ final class AppState: ObservableObject {
let detector: AppDetector let detector: AppDetector
let hotkeys: HotkeyManager let hotkeys: HotkeyManager
let dictionary: PersonalDictionary let dictionary: PersonalDictionary
let preferences: Preferences
let usage: UsageStats
let customModes: CustomModesStore
let snippets: SnippetStore
private let capture: TextCapture private let capture: TextCapture
let coordinator: TextReplacementCoordinator let coordinator: TextReplacementCoordinator
@ -30,6 +34,7 @@ final class AppState: ObservableObject {
private var onboardingWindow: NSWindow? private var onboardingWindow: NSWindow?
private var askWindow: NSWindow? private var askWindow: NSWindow?
private var mainWindow: NSWindow?
private init() { private init() {
let harper = HarperEngine() let harper = HarperEngine()
@ -43,6 +48,10 @@ final class AppState: ObservableObject {
capture = TextCapture() capture = TextCapture()
hotkeys = HotkeyManager() hotkeys = HotkeyManager()
dictionary = PersonalDictionary() dictionary = PersonalDictionary()
preferences = Preferences()
usage = UsageStats()
customModes = CustomModesStore()
snippets = SnippetStore()
coordinator = TextReplacementCoordinator( coordinator = TextReplacementCoordinator(
capture: capture, grammar: harper, ai: ai, gate: gate, detector: detector) capture: capture, grammar: harper, ai: ai, gate: gate, detector: detector)
} }
@ -81,16 +90,24 @@ final class AppState: ObservableObject {
} }
func runFix() async { 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 { func runAsk() async {
let instruction = askText.trimmingCharacters(in: .whitespacesAndNewlines) let instruction = askText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instruction.isEmpty else { return } 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() 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). /// Apply the Mode suggested by the frontmost app (App-Aware, paid).
func runAppAwareRewrite() async { func runAppAwareRewrite() async {
guard gate.appAwareEnabled else { guard gate.appAwareEnabled else {
@ -101,7 +118,9 @@ final class AppState: ObservableObject {
lastStatus = "No mode mapped for the current app." lastStatus = "No mode mapped for the current app."
return 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) { private func present(_ outcome: ProcessOutcome) {
@ -145,6 +164,34 @@ final class AppState: ObservableObject {
onboardingWindow?.makeKeyAndOrderFront(nil) 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() { func showAsk() {
askText = "" askText = ""
if askWindow == nil { if askWindow == nil {

View File

@ -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)
}
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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<Content: View>: 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<Content: View>: 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)
}
}

View File

@ -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()
}
}
}

View File

@ -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
}
}

View File

@ -25,6 +25,15 @@ struct MenuBarContent: View {
Divider() Divider()
actionRow(title: "Open GrammaGem…", shortcut: nil) {
app.showMainWindow()
}
actionRow(title: "Manage devices…", shortcut: nil) {
app.showMainWindow(select: .devices)
}
Divider()
usageRow usageRow
Divider() Divider()

View File

@ -6,9 +6,16 @@ import Foundation
/// TODO(real-integration): replace every `TODO_*` value with your real IDs. /// TODO(real-integration): replace every `TODO_*` value with your real IDs.
enum AppConfig { enum AppConfig {
static let appName = "GrammaGem" 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 supportEmail = "support@grammagem.app" // TODO(real-integration)
static let modelPortalURL = URL(string: "https://grammagem.app/license")! // TODO 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). /// Lemon Squeezy License API (app talks to it directly — no server of ours).
enum LemonSqueezy { enum LemonSqueezy {

View File

@ -32,6 +32,11 @@ enum DeviceFingerprint {
return cf.takeRetainedValue() as? String 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 { private static func fallbackID() -> String {
let key = "GrammaGem.fallbackDeviceID" let key = "GrammaGem.fallbackDeviceID"
if let existing = UserDefaults.standard.string(forKey: key) { return existing } if let existing = UserDefaults.standard.string(forKey: key) { return existing }

View File

@ -13,7 +13,7 @@ final class LemonSqueezyClient {
// MARK: - Endpoints // 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( let res: LSActivateResponse = try await post(
"licenses/activate", "licenses/activate",
params: ["license_key": key, "instance_name": instanceName]) params: ["license_key": key, "instance_name": instanceName])
@ -23,17 +23,23 @@ final class LemonSqueezyClient {
throw LicenseError.serverMessage("Activation failed.") throw LicenseError.serverMessage("Activation failed.")
} }
let tier = try verifyAndResolveTier(meta: res.meta, licenseKey: res.license_key) 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] var params = ["license_key": key]
if let instanceID { params["instance_id"] = instanceID } if let instanceID { params["instance_id"] = instanceID }
let res: LSValidateResponse = try await post("licenses/validate", params: params) let res: LSValidateResponse = try await post("licenses/validate", params: params)
if let err = res.error { throw mapServerError(err) } if let err = res.error { throw mapServerError(err) }
let tier = try verifyAndResolveTier(meta: res.meta, licenseKey: res.license_key) 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 @discardableResult

View File

@ -44,13 +44,15 @@ final class LicenseManager: ObservableObject {
do { do {
let instanceName = DeviceFingerprint.stableID() 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( let rec = LicenseRecord(
key: key, instanceID: instanceID, tier: tier, key: key, instanceID: result.instanceID, tier: result.tier,
lastValidated: Date(), activatedDevices: tier.deviceCap) lastValidated: Date(), activatedAt: Date(),
deviceName: DeviceFingerprint.deviceName(),
activationUsage: result.usage, activationLimit: result.limit)
Keychain.setObject(rec, account: account) Keychain.setObject(rec, account: account)
record = rec record = rec
Log.licensing.info("Activated tier \(tier.rawValue, privacy: .public)") Log.licensing.info("Activated tier \(result.tier.rawValue, privacy: .public)")
} catch { } catch {
lastError = error.localizedDescription lastError = error.localizedDescription
} }
@ -63,10 +65,12 @@ final class LicenseManager: ObservableObject {
guard force || Date().timeIntervalSince(rec.lastValidated) > interval else { return } guard force || Date().timeIntervalSince(rec.lastValidated) > interval else { return }
do { do {
let (valid, tier) = try await client.validate(key: rec.key, instanceID: rec.instanceID) let result = try await client.validate(key: rec.key, instanceID: rec.instanceID)
if valid { if result.valid {
rec.lastValidated = Date() rec.lastValidated = Date()
rec.tier = tier rec.tier = result.tier
rec.activationUsage = result.usage
rec.activationLimit = result.limit
Keychain.setObject(rec, account: account) Keychain.setObject(rec, account: account)
record = rec record = rec
} else { } else {

View File

@ -6,7 +6,26 @@ struct LicenseRecord: Codable, Equatable {
var instanceID: String // Lemon Squeezy activation instance id var instanceID: String // Lemon Squeezy activation instance id
var tier: Tier var tier: Tier
var lastValidated: Date 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) // MARK: - Lemon Squeezy API response shapes (subset we rely on)

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}