mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
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:
parent
f5faa27f88
commit
a7bf45b466
@ -36,7 +36,7 @@
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© 2026 GrammaGem. Built in public.</string>
|
||||
<string>© 2026 FounderGem LLC. Built in public.</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
</dict>
|
||||
|
||||
2
LICENSE
2
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
|
||||
|
||||
179
README.md
179
README.md
@ -1,125 +1,102 @@
|
||||
# GrammaGem (macOS app)
|
||||
<div align="center">
|
||||
|
||||
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.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
[**⬇️ 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.
|
||||
|
||||
```
|
||||
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.
|
||||
<div align="center">
|
||||
|
||||
## 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 |
|
||||
</div>
|
||||
|
||||
## 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.
|
||||
|
||||
<div align="center">
|
||||
|
||||
**[grammagem.app](https://grammagem.app)**
|
||||
|
||||
© 2026 FounderGem LLC · 1309 Coffeen Avenue STE 1200, Sheridan, WY 82801, USA
|
||||
|
||||
</div>
|
||||
|
||||
71
Sources/GrammaGem/AI/CustomModesStore.swift
Normal file
71
Sources/GrammaGem/AI/CustomModesStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
167
Sources/GrammaGem/App/MainWindow/AccountViews.swift
Normal file
167
Sources/GrammaGem/App/MainWindow/AccountViews.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
132
Sources/GrammaGem/App/MainWindow/DashboardView.swift
Normal file
132
Sources/GrammaGem/App/MainWindow/DashboardView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
118
Sources/GrammaGem/App/MainWindow/DataViews.swift
Normal file
118
Sources/GrammaGem/App/MainWindow/DataViews.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
Sources/GrammaGem/App/MainWindow/DevicesView.swift
Normal file
91
Sources/GrammaGem/App/MainWindow/DevicesView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
179
Sources/GrammaGem/App/MainWindow/MainWindowView.swift
Normal file
179
Sources/GrammaGem/App/MainWindow/MainWindowView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
139
Sources/GrammaGem/App/MainWindow/SystemViews.swift
Normal file
139
Sources/GrammaGem/App/MainWindow/SystemViews.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
131
Sources/GrammaGem/App/MainWindow/WritingModesView.swift
Normal file
131
Sources/GrammaGem/App/MainWindow/WritingModesView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
55
Sources/GrammaGem/Preferences/Preferences.swift
Normal file
55
Sources/GrammaGem/Preferences/Preferences.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
90
Sources/GrammaGem/Stats/UsageStats.swift
Normal file
90
Sources/GrammaGem/Stats/UsageStats.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
51
Sources/GrammaGem/System/SnippetStore.swift
Normal file
51
Sources/GrammaGem/System/SnippetStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user