GrammaGem — open-source, on-device macOS writing assistant

Native SwiftUI menu-bar app: global hotkey → capture selected text (AX API /
clipboard fallback) → grammar (Harper) / local AI (MLX) → replace in place.
Free/paid entitlement gate + Lemon Squeezy device-cap licensing, $0 marginal
cost per user. Heavyweight engines behind protocol stubs so it builds out of the box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BigMarc 2026-06-16 13:56:26 +02:00
commit f5faa27f88
42 changed files with 2709 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.build/
dist/
*.xcodeproj/xcuserdata/
.DS_Store
.swiftpm/

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Entitlements for codesigning the notarized release.
IMPORTANT: a system-wide tool that reads/replaces text in OTHER apps (via the
Accessibility API + synthetic keystrokes) is INCOMPATIBLE with the App Sandbox,
so the sandbox is intentionally OFF. Notarization still requires the Hardened
Runtime (enabled at signing time with `--options runtime`, not via this file).
The network client entitlement is listed for completeness (model download +
license API). No server-side anything; no sensitive entitlements beyond this.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

43
AppSupport/Info.plist Normal file
View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Info.plist for the packaged GrammaGem.app bundle.
`swift build` produces a bare executable; to ship a real menu-bar app you wrap
it in a .app bundle with this Info.plist (see scripts/build.sh) or, more
commonly, build from an Xcode app target that uses this file.
LSUIElement = menu-bar app with no Dock icon. (The code also calls
NSApp.setActivationPolicy(.accessory) so it behaves correctly even when run
as a bare SwiftPM executable.)
NOTE: Accessibility permission is requested at runtime via AXIsProcessTrusted
and has no Info.plist usage-string key. Input Monitoring (for synthetic ⌘C/⌘V
in the clipboard fallback) is likewise system-managed.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>GrammaGem</string>
<key>CFBundleDisplayName</key>
<string>GrammaGem</string>
<key>CFBundleIdentifier</key>
<string>com.grammagem.app</string>
<key>CFBundleExecutable</key>
<string>GrammaGem</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>© 2026 GrammaGem. Built in public.</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 GrammaGem
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

37
Package.swift Normal file
View File

@ -0,0 +1,37 @@
// swift-tools-version:5.10
import PackageDescription
// GrammaGem — native macOS menu-bar writing assistant.
//
// This package builds with ONLY Apple frameworks so it compiles cleanly out of
// the box (`swift build`). The heavyweight third-party pieces from the spec —
// Harper (Rust grammar core), MLX (local LLM), KeyboardShortcuts, Sparkle — are
// abstracted behind protocols with working stub implementations and clearly
// marked `TODO(real-integration)` seams. See README.md for wiring the real deps.
let package = Package(
name: "GrammaGem",
platforms: [
.macOS(.v14) // Apple Silicon, macOS 14+ (per the product spec)
],
products: [
.executable(name: "GrammaGem", targets: ["GrammaGem"])
],
dependencies: [
// TODO(real-integration): add when wiring real engines / distribution.
// .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.0.0"),
// .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"),
// .package(url: "https://github.com/ml-explore/mlx-swift", from: "0.18.0"),
// The Harper grammar core is bundled via a Rust static lib + C-FFI; see harper-ffi/.
],
targets: [
.executableTarget(
name: "GrammaGem",
path: "Sources/GrammaGem"
),
.testTarget(
name: "GrammaGemTests",
dependencies: ["GrammaGem"],
path: "Tests/GrammaGemTests"
),
]
)

125
README.md Normal file
View File

@ -0,0 +1,125 @@
# GrammaGem (macOS app)
A private, **on-device** writing assistant for macOS — a one-time-purchase
replacement for Grammarly. Fix grammar, sharpen tone, and rewrite anything in
**any app** via a global hotkey. Your words never leave your Mac.
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.
---
## Architecture (two layers)
```
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),
license activate/validate (Lemon Squeezy), and update checks (Sparkle/GitHub
Releases). **Writing never requires internet. No telemetry.**
## Repo layout
```
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
```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
# 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.
> Prefer Xcode? Open `Package.swift` directly in Xcode 26 (File → Open). For a
> production app target, point it at `AppSupport/Info.plist` + entitlements.
## 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)`.
| 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) |
### 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).
## Honest gaps (by design)
- **No plagiarism detection.** It needs a server-side corpus + live index, which
breaks both the $0-cost rule and the privacy promise. Out of scope, not faked.
- **The gate is intentionally soft.** Because the code is public, a technical
user could compile a build with the gate removed — that's the VoiceInk model
and it's fine. The product is the signed build + license + support, not secrecy.
## Cost model — $0 marginal cost per customer
| Item | Cost |
|------|------|
| Server / database | $0 (none exists) |
| AI inference (on device) | $0 |
| Model hosting (Hugging Face) | $0 |
| Update hosting (GitHub Releases) | $0 |
| License validation (Lemon Squeezy) | $0 (included) |
| Apple Developer Program | $99 / year (fixed) |
| Payment processor | ~5% + fee per sale |
## License
Permissive (MIT/Apache-2.0) to maximize trust and stars. Monetization is the
signed binary + multi-device license, not the source.

14
Resources/Modes/README.md Normal file
View File

@ -0,0 +1,14 @@
# Built-in Writing Modes
Each Mode is a named system-prompt preset for the local LLM. The canonical,
compiled definitions live in [`Sources/GrammaGem/AI/ModeRegistry.swift`](../../Sources/GrammaGem/AI/ModeRegistry.swift);
these JSON files mirror them for transparency and easy editing.
| Mode | Tier | In public repo? |
|------|------|-----------------|
| `polish` | Free | ✅ Yes — the open free core |
| `email`, `post`, `slack`, `academic`, `code` | Paid | Baseline prompts only |
**Soft deterrent (spec §5):** ship the *fully-tuned* paid-mode prompts only in
the signed release build, not in the public repository. The free `polish` mode
is fully open. Custom user Modes (paid) are stored locally per user.

View File

@ -0,0 +1,9 @@
{
"id": "email",
"name": "Email",
"isPaid": true,
"lengthCap": 120,
"autoFormat": "Greeting + sign-off",
"_note": "PAID mode. Ship the fully-tuned prompt only in the signed release, not the public repo (spec §5 soft deterrent). This is a readable baseline.",
"systemPrompt": "Rewrite the user's text as a professional, warm email. Keep it concise and natural — never robotic. Add a brief greeting and sign-off only if appropriate. Return only the email body."
}

View File

@ -0,0 +1,8 @@
{
"id": "polish",
"name": "Polish",
"isPaid": false,
"lengthCap": null,
"autoFormat": "Preserve original",
"systemPrompt": "You are a careful copy editor. Lightly clean up the user's text: fix grammar, spelling, and punctuation, and improve clarity, while preserving their voice, meaning, and formatting. Return only the revised text with no commentary."
}

View File

@ -0,0 +1,32 @@
import Foundation
/// Layer 2 — local-LLM-powered rewriting / tone / "Ask" / translate.
///
/// Backed in production by **MLX** (mlx-swift) running a small instruct model
/// downloaded once from Hugging Face. The protocol isolates the rest of the app
/// from the runtime so the UI, gating, and tests don't depend on MLX being present.
protocol AIEngine: AnyObject {
/// Whether a model is loaded and ready to run locally.
var isReady: Bool { get }
/// Run an action against `text` and return the rewritten result.
/// Throws on model-not-ready or generation failure. Never performs network I/O.
func run(_ action: AIAction, on text: String) async throws -> String
/// Best-effort local tone classification (used by tone-detection UI).
func detectTone(_ text: String) async -> Tone
}
enum AIEngineError: LocalizedError {
case modelNotReady
case generationFailed(String)
var errorDescription: String? {
switch self {
case .modelNotReady:
return "The on-device model isn't ready yet. Finish the first-run download in Settings."
case .generationFailed(let why):
return "On-device rewrite failed: \(why)"
}
}
}

View File

@ -0,0 +1,91 @@
import Foundation
/// STUB standing in for the real MLX-backed local LLM.
///
/// TODO(real-integration): load the model via mlx-swift from
/// `ModelManager.modelsDirectory`, build the prompt from the action's system
/// prompt + the user's text, and stream tokens (target: first token < 1s on
/// M-series). Everything stays on-device — this method must never hit the network.
///
/// The stub produces deterministic, plausible transformations so the capture →
/// engine → replace loop and the entitlement gating can be exercised end-to-end
/// without the multi-gigabyte model present.
final class MLXEngine: AIEngine {
private let ready: Bool
init(ready: Bool = true) {
self.ready = ready
}
var isReady: Bool { ready }
func run(_ action: AIAction, on text: String) async throws -> String {
guard ready else { throw AIEngineError.modelNotReady }
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return text }
switch action {
case .rewriteClarity, .rewrite:
return tidy(trimmed)
case .adjustTone(let tone):
return apply(tone: tone, to: tidy(trimmed))
case .ask(let instruction):
return follow(instruction: instruction, on: trimmed)
case .translate(let language):
return "[\(language)] " + tidy(trimmed)
case .applyMode(let mode):
return applyMode(mode, to: trimmed)
}
}
func detectTone(_ text: String) async -> Tone {
let lower = text.lowercased()
if lower.contains("!") || lower.contains("🚀") { return .punchy }
if lower.contains("therefore") || lower.contains("hereby") { return .academic }
if lower.contains("hey") || lower.contains("lol") { return .friendly }
return .professional
}
// MARK: - Deterministic stand-in transforms
private func tidy(_ s: String) -> String {
var out = s
out = out.replacingOccurrences(of: " i ", with: " I ")
out = out.replacingOccurrences(of: "lmk", with: "let me know")
out = out.replacingOccurrences(of: "u ", with: "you ")
if let first = out.first, first.isLowercase {
out.replaceSubrange(out.startIndex...out.startIndex, with: String(first).uppercased())
}
if let last = out.last, !".!?".contains(last) { out.append(".") }
return out
}
private func apply(tone: Tone, to s: String) -> String {
switch tone {
case .professional: return s
case .friendly: return "Hi! " + s
case .confident: return s.replacingOccurrences(of: "I think ", with: "")
case .academic: return "It is worth noting that " + s.prefix(1).lowercased() + s.dropFirst()
case .punchy: return s.replacingOccurrences(of: ". ", with: ".\n")
}
}
private func follow(instruction: String, on s: String) -> String {
let i = instruction.lowercased()
if i.contains("short") { return String(s.split(separator: " ").prefix(8).joined(separator: " ")) + "." }
if i.contains("formal") { return apply(tone: .academic, to: tidy(s)) }
if i.contains("translate") { return "[translated] " + tidy(s) }
return tidy(s)
}
private func applyMode(_ mode: WritingMode, to s: String) -> String {
switch mode.id {
case "email": return "Hi,\n\n\(tidy(s))\n\nThanks!"
case "post": return apply(tone: .punchy, to: tidy(s))
case "academic": return apply(tone: .academic, to: tidy(s))
case "slack": return s.lowercased()
case "code": return "// " + tidy(s)
default: return tidy(s)
}
}
}

View File

@ -0,0 +1,111 @@
import Foundation
/// Built-in Writing Modes (system-prompt presets for the local LLM).
///
/// Per the spec's soft-deterrent note (§5), the *free* mode (`polish`) ships in
/// the public repo, while the paid modes' fully-tuned prompts ship only in the
/// signed release. Here we include readable baseline prompts for all of them;
/// swap the paid ones for your tuned versions in the release build.
enum ModeRegistry {
static let polish = WritingMode(
id: "polish",
name: "Polish",
systemPrompt: """
You are a careful copy editor. Lightly clean up the user's text: fix \
grammar, spelling, and punctuation, and improve clarity, while \
preserving their voice, meaning, and formatting. Return only the \
revised text with no commentary.
""",
isPaid: false,
lengthCap: nil,
autoFormat: "Preserve original"
)
static let email = WritingMode(
id: "email",
name: "Email",
systemPrompt: """
Rewrite the user's text as a professional, warm email. Keep it concise \
and natural — never robotic. Add a brief greeting and sign-off only if \
appropriate. Return only the email body.
""",
isPaid: true,
lengthCap: 120,
autoFormat: "Greeting + sign-off"
)
static let post = WritingMode(
id: "post",
name: "Post",
systemPrompt: """
Rewrite the user's text as a punchy social post. Short lines, strong \
verbs, no fluff. Keep it under 280 characters. Return only the post.
""",
isPaid: true,
lengthCap: 280,
autoFormat: "Line breaks, no fluff"
)
static let slack = WritingMode(
id: "slack",
name: "Team Chat",
systemPrompt: """
Rewrite the user's text as a friendly, direct team-chat message. \
Conversational, lowercase is fine, get to the point. Return only the message.
""",
isPaid: true,
lengthCap: 60,
autoFormat: "Inline, casual"
)
static let academic = WritingMode(
id: "academic",
name: "Academic",
systemPrompt: """
Rewrite the user's text in a formal, academic register. Hedge claims \
appropriately, prefer precise vocabulary, and use complete sentences. \
Return only the revised text.
""",
isPaid: true,
lengthCap: nil,
autoFormat: "Formal, hedged"
)
static let code = WritingMode(
id: "code",
name: "Code Comment",
systemPrompt: """
Rewrite the user's text as a clear code comment / commit message: \
imperative mood, concise, technical. Return only the comment text.
""",
isPaid: true,
lengthCap: nil,
autoFormat: "Imperative, terse"
)
/// Everything the app knows about.
static let all: [WritingMode] = [polish, email, post, slack, academic, code]
/// Modes a given tier may use (free: Polish only; paid: all + custom).
static func available(for tier: Tier) -> [WritingMode] {
tier.isPaid ? all : all.filter { !$0.isPaid }
}
static func mode(id: String) -> WritingMode? {
all.first { $0.id == id }
}
/// Map a frontmost-app bundle identifier to the Mode it should auto-apply.
/// Used by the App-Aware feature (paid).
static func mode(forBundleID bundleID: String) -> WritingMode? {
switch bundleID {
case "com.apple.mail": return email
case "com.tinyspeck.slackmacgap": return slack
case "com.apple.MobileSMS": return slack
case "notion.id", "com.notion.desktop": return polish
case "com.microsoft.VSCode", "com.apple.dt.Xcode": return code
case "com.apple.Safari", "com.google.Chrome": return polish
default: return nil
}
}
}

View File

@ -0,0 +1,58 @@
import Foundation
/// Manages the one-time download of the local LLM weights from Hugging Face.
/// This is one of the only three network touchpoints in the product (model
/// download, license activate/validate, update checks) — and it never sends
/// user text.
@MainActor
final class ModelManager: ObservableObject {
enum State: Equatable {
case notDownloaded
case downloading(progress: Double)
case ready
case failed(String)
}
@Published private(set) var state: State = .notDownloaded
@Published var selectedRepo: String = AppConfig.Model.defaultRepo
/// Local cache directory for downloaded weights.
var modelsDirectory: URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
return base.appendingPathComponent("GrammaGem/Models", isDirectory: true)
}
func isModelPresent(repo: String) -> Bool {
let dir = modelsDirectory.appendingPathComponent(repo.replacingOccurrences(of: "/", with: "_"))
return FileManager.default.fileExists(atPath: dir.path)
}
/// TODO(real-integration): stream the model files from Hugging Face into
/// `modelsDirectory` (resumable, with progress), then hand the path to the
/// MLX runtime. This stub simulates the lifecycle so onboarding UI works.
func download(repo: String? = nil) async {
let target = repo ?? selectedRepo
selectedRepo = target
if isModelPresent(repo: target) {
state = .ready
return
}
do {
try FileManager.default.createDirectory(at: modelsDirectory, withIntermediateDirectories: true)
} catch {
state = .failed(error.localizedDescription)
return
}
// Simulated progress (replace with real URLSession download tasks).
state = .downloading(progress: 0)
for step in 1...10 {
try? await Task.sleep(nanoseconds: 120_000_000)
state = .downloading(progress: Double(step) / 10.0)
}
Log.ai.info("Model marked ready (stubbed download): \(target, privacy: .public)")
state = .ready
}
}

View File

@ -0,0 +1,197 @@
import Foundation
import AppKit
import SwiftUI
/// Root application model. Owns the engines, managers, and the system-wide
/// coordinator, wires the global hotkeys, and presents the on-demand windows.
@MainActor
final class AppState: ObservableObject {
static let shared = AppState()
// Engines (Layer 1 + Layer 2)
let grammar: GrammarEngine
let ai: AIEngine
// Managers
let permissions: Permissions
let model: ModelManager
let license: LicenseManager
let gate: FeatureGate
let detector: AppDetector
let hotkeys: HotkeyManager
let dictionary: PersonalDictionary
private let capture: TextCapture
let coordinator: TextReplacementCoordinator
// UI state
@Published var lastStatus: String = "Ready"
@Published var askText: String = ""
@Published var appAwareEnabled: Bool = false
private var onboardingWindow: NSWindow?
private var askWindow: NSWindow?
private init() {
let harper = HarperEngine()
grammar = harper
ai = MLXEngine(ready: true)
permissions = Permissions()
model = ModelManager()
license = LicenseManager()
gate = FeatureGate(license: license)
detector = AppDetector()
capture = TextCapture()
hotkeys = HotkeyManager()
dictionary = PersonalDictionary()
coordinator = TextReplacementCoordinator(
capture: capture, grammar: harper, ai: ai, gate: gate, detector: detector)
}
/// Called once at launch (from the AppDelegate).
func start() {
// Menu-bar app: no Dock icon (works even without LSUIElement in Info.plist).
NSApp.setActivationPolicy(.accessory)
hotkeys.onAction = { [weak self] action in
Task { await self?.handle(action) }
}
hotkeys.registerDefaults()
permissions.refresh()
appAwareEnabled = gate.appAwareEnabled
syncIgnoreList()
if !permissions.accessibilityTrusted {
showOnboarding()
}
Task { await license.validateIfNeeded() }
Log.app.info("GrammaGem started. Licensed: \(self.license.isLicensed, privacy: .public)")
}
// MARK: - Hotkey handling
func handle(_ action: HotkeyManager.Action) async {
switch action {
case .fix:
await runFix()
case .ask:
showAsk()
}
}
func runFix() async {
present(await coordinator.handleFix())
}
func runAsk() async {
let instruction = askText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instruction.isEmpty else { return }
present(await coordinator.handleAsk(instruction))
askWindow?.close()
}
/// Apply the Mode suggested by the frontmost app (App-Aware, paid).
func runAppAwareRewrite() async {
guard gate.appAwareEnabled else {
present(.blockedByEntitlement("App-aware switching is part of the lifetime upgrade."))
return
}
guard let mode = detector.suggestedMode() else {
lastStatus = "No mode mapped for the current app."
return
}
present(await coordinator.handleApplyMode(mode))
}
private func present(_ outcome: ProcessOutcome) {
switch outcome {
case .replaced:
lastStatus = "Fixed ✓"
case .noSelection:
lastStatus = "Select some text first."
case .blockedByEntitlement(let reason):
lastStatus = reason
notify(title: "Upgrade required", body: reason)
case .failed(let message):
lastStatus = message
}
}
private func notify(title: String, body: String) {
// Lightweight, content-free alert (no user text ever leaves the device).
let alert = NSAlert()
alert.messageText = title
alert.informativeText = body
alert.addButton(withTitle: "OK")
NSApp.activate(ignoringOtherApps: true)
alert.runModal()
}
// MARK: - On-demand windows (AppKit-hosted SwiftUI; reliable for menu-bar apps)
func showOnboarding() {
if onboardingWindow == nil {
let host = NSHostingController(rootView: OnboardingView().environmentObject(self))
let win = NSWindow(contentViewController: host)
win.title = "Welcome to GrammaGem"
win.styleMask = [.titled, .closable, .fullSizeContentView]
win.setContentSize(NSSize(width: 540, height: 600))
win.isReleasedWhenClosed = false
win.center()
onboardingWindow = win
}
NSApp.activate(ignoringOtherApps: true)
onboardingWindow?.makeKeyAndOrderFront(nil)
}
func showAsk() {
askText = ""
if askWindow == nil {
let host = NSHostingController(rootView: AskView().environmentObject(self))
let win = NSWindow(contentViewController: host)
win.title = "Ask GrammaGem"
win.styleMask = [.titled, .closable]
win.setContentSize(NSSize(width: 460, height: 220))
win.isReleasedWhenClosed = false
win.center()
win.level = .floating
askWindow = win
}
NSApp.activate(ignoringOtherApps: true)
askWindow?.makeKeyAndOrderFront(nil)
}
func showOnboardingDismiss() {
onboardingWindow?.close()
}
func showAskDismiss() {
askWindow?.close()
}
// MARK: - Personal dictionary (gated adds)
@discardableResult
func addDictionaryEntry(_ word: String) -> FeatureGate.Decision {
let decision = gate.canAddDictionaryEntry(currentCount: dictionary.entries.count)
if case .allowed = decision {
dictionary.add(word)
syncIgnoreList()
}
return decision
}
func removeDictionaryEntry(_ word: String) {
dictionary.remove(word)
syncIgnoreList()
}
private func syncIgnoreList() {
grammar.ignoreList = Set(dictionary.entries.map { $0.lowercased() })
}
func quit() {
NSApp.terminate(nil)
}
}

View File

@ -0,0 +1,39 @@
import SwiftUI
/// The "Ask" popover: type an instruction ("make it shorter", "translate to
/// German") that the local LLM applies to the current selection, in place.
struct AskView: View {
@EnvironmentObject private var app: AppState
@FocusState private var focused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Ask GrammaGem")
.font(.headline)
Text("Acts on the text selected in the frontmost app. Runs on-device.")
.font(.caption)
.foregroundStyle(.secondary)
TextField("e.g. make it more formal", text: $app.askText)
.textFieldStyle(.roundedBorder)
.focused($focused)
.onSubmit { Task { await app.runAsk() } }
HStack {
if !app.gate.entitlements.unlimitedAIActions {
Text("\(app.gate.remainingAIActionsToday) free actions left today")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Cancel") { app.showAskDismiss() }
Button("Run") { Task { await app.runAsk() } }
.keyboardShortcut(.defaultAction)
.disabled(app.askText.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.padding(20)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear { focused = true }
}
}

View File

@ -0,0 +1,32 @@
import SwiftUI
import AppKit
/// App entry point. A menu-bar (`LSUIElement`) SwiftUI app: the only persistent
/// scene is the `MenuBarExtra`; Settings uses the standard scene; Onboarding and
/// Ask are AppKit-hosted windows opened on demand (see `AppState`).
@main
struct GrammaGemApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@StateObject private var app = AppState.shared
var body: some Scene {
MenuBarExtra("GrammaGem", systemImage: "pencil.and.scribble") {
MenuBarContent()
.environmentObject(app)
}
.menuBarExtraStyle(.window)
Settings {
SettingsView()
.environmentObject(app)
.frame(width: 560, height: 520)
}
}
}
/// Runs launch-time wiring (hotkeys, permissions, license validation).
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
AppState.shared.start()
}
}

View File

@ -0,0 +1,101 @@
import SwiftUI
/// The dropdown shown from the menu-bar icon.
struct MenuBarContent: View {
@EnvironmentObject private var app: AppState
@Environment(\.openSettings) private var openSettings
var body: some View {
VStack(alignment: .leading, spacing: 10) {
header
Divider()
actionRow(title: "Fix selection", shortcut: "⌘;") {
Task { await app.runFix() }
}
actionRow(title: "Ask GrammaGem…", shortcut: "⌘'") {
app.showAsk()
}
if app.gate.appAwareEnabled {
actionRow(title: "Apply app-aware mode", shortcut: nil) {
Task { await app.runAppAwareRewrite() }
}
}
Divider()
usageRow
Divider()
HStack {
Button("Settings…") { openSettings() }
Spacer()
Button("Quit") { app.quit() }
}
.buttonStyle(.borderless)
Text(app.lastStatus)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
.padding(14)
.frame(width: 280)
}
private var header: some View {
HStack(spacing: 8) {
Image(systemName: "pencil.and.scribble")
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 1) {
Text("GrammaGem").font(.headline)
Text(app.license.tier.displayName + (app.license.isLicensed ? " · licensed" : " · free"))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if !app.permissions.accessibilityTrusted {
Button {
app.showOnboarding()
} label: {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
.buttonStyle(.borderless)
.help("Accessibility permission needed")
}
}
}
@ViewBuilder
private var usageRow: some View {
if app.gate.entitlements.unlimitedAIActions {
Label("Unlimited AI actions", systemImage: "infinity")
.font(.caption)
.foregroundStyle(.secondary)
} else {
let used = app.gate.aiActionsUsedToday
let cap = app.gate.entitlements.dailyAIActionCap
Label("\(max(0, cap - used)) of \(cap) free AI actions left today",
systemImage: "bolt")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func actionRow(title: String, shortcut: String?, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
Text(title)
Spacer()
if let shortcut {
Text(shortcut).foregroundStyle(.secondary).font(.callout.monospaced())
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}

View File

@ -0,0 +1,105 @@
import SwiftUI
/// First-run onboarding: explains why GrammaGem needs Accessibility, deep-links
/// to System Settings, and advances automatically once permission is granted.
struct OnboardingView: View {
@EnvironmentObject private var app: AppState
var body: some View {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 6) {
Text("Welcome to GrammaGem")
.font(.largeTitle.bold())
Text("A private, on-device writing assistant that works in every app.")
.foregroundStyle(.secondary)
}
stepCard(
number: 1,
title: "Grant Accessibility access",
body: "GrammaGem reads the text you select and writes the correction back. macOS requires Accessibility permission for this. Your text is processed on-device and never leaves your Mac.",
granted: app.permissions.accessibilityTrusted
) {
HStack {
Button("Open System Settings") { app.permissions.openAccessibilitySettings() }
Button("Request permission") {
app.permissions.requestAccessibility()
app.permissions.startPollingUntilGranted()
}
}
}
stepCard(
number: 2,
title: "Download the on-device model (optional)",
body: "Grammar & spelling work instantly with no download. The AI rewrite/tone/Ask features use a small model downloaded once from Hugging Face — then everything runs offline.",
granted: app.model.state == .ready
) {
ModelDownloadControls()
.environmentObject(app)
}
Spacer()
HStack {
Spacer()
Button(app.permissions.accessibilityTrusted ? "Done" : "Continue without permission") {
app.showOnboardingDismiss()
}
.keyboardShortcut(.defaultAction)
}
}
.padding(28)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear { app.permissions.refresh() }
}
private func stepCard<Controls: View>(
number: Int, title: String, body: String, granted: Bool,
@ViewBuilder controls: () -> Controls
) -> some View {
HStack(alignment: .top, spacing: 14) {
ZStack {
Circle().fill(granted ? Color.green : Color.accentColor.opacity(0.15))
.frame(width: 30, height: 30)
if granted {
Image(systemName: "checkmark").foregroundStyle(.white).font(.caption.bold())
} else {
Text("\(number)").font(.callout.bold()).foregroundStyle(.tint)
}
}
VStack(alignment: .leading, spacing: 8) {
Text(title).font(.headline)
Text(body).font(.callout).foregroundStyle(.secondary)
controls()
}
}
.padding(16)
.background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 12))
}
}
/// Model download progress + trigger, shared by onboarding and settings.
struct ModelDownloadControls: View {
@EnvironmentObject private var app: AppState
var body: some View {
switch app.model.state {
case .notDownloaded:
Button("Download model") { Task { await app.model.download() } }
case .downloading(let progress):
ProgressView(value: progress) { Text("Downloading… \(Int(progress * 100))%") }
.frame(maxWidth: 260)
case .ready:
Label("Model ready", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
case .failed(let message):
VStack(alignment: .leading) {
Label("Download failed", systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
Text(message).font(.caption).foregroundStyle(.secondary)
Button("Retry") { Task { await app.model.download() } }
}
}
}
}

View File

@ -0,0 +1,192 @@
import SwiftUI
struct SettingsView: View {
var body: some View {
TabView {
GeneralSettings()
.tabItem { Label("General", systemImage: "gearshape") }
LicenseSettings()
.tabItem { Label("License", systemImage: "key") }
ModelSettings()
.tabItem { Label("Model", systemImage: "cpu") }
DictionarySettings()
.tabItem { Label("Dictionary", systemImage: "character.book.closed") }
}
.padding(20)
}
}
// MARK: - General
private struct GeneralSettings: View {
@EnvironmentObject private var app: AppState
var body: some View {
Form {
Section("Shortcuts") {
LabeledContent("Fix selection", value: "⌘;")
LabeledContent("Ask GrammaGem", value: "⌘'")
Text("Custom shortcuts coming via the KeyboardShortcuts recorder (see README).")
.font(.caption).foregroundStyle(.secondary)
}
Section("App-aware switching") {
if app.gate.appAwareEnabled {
Toggle("Auto-apply a Writing Mode based on the active app", isOn: $app.appAwareEnabled)
} else {
Label("App-aware switching is part of the lifetime upgrade.", systemImage: "lock")
.foregroundStyle(.secondary)
}
}
Section("Permissions") {
LabeledContent("Accessibility",
value: app.permissions.accessibilityTrusted ? "Granted" : "Needed")
if !app.permissions.accessibilityTrusted {
Button("Open onboarding") { app.showOnboarding() }
}
}
}
.formStyle(.grouped)
}
}
// MARK: - License
private struct LicenseSettings: View {
@EnvironmentObject private var app: AppState
@State private var keyField = ""
var body: some View {
Form {
Section("Plan") {
LabeledContent("Current tier", value: app.license.tier.displayName)
LabeledContent("Device cap", value: "\(app.license.tier.deviceCap) Mac\(app.license.tier.deviceCap == 1 ? "" : "s")")
}
if app.license.isLicensed {
Section("This device") {
Button(app.license.isWorking ? "Working…" : "Deactivate this device") {
Task { await app.license.deactivateThisDevice() }
}
.disabled(app.license.isWorking)
Text("Frees a slot so you can move the license to another Mac.")
.font(.caption).foregroundStyle(.secondary)
}
} else {
Section("Activate") {
TextField("Paste your license key", text: $keyField)
.textFieldStyle(.roundedBorder)
Button(app.license.isWorking ? "Activating…" : "Activate") {
Task { await app.license.activate(key: keyField) }
}
.disabled(app.license.isWorking || keyField.isEmpty)
}
}
if let error = app.license.lastError {
Section {
Label(error, systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
}
Section {
Link("Manage your license", destination: AppConfig.modelPortalURL)
}
}
.formStyle(.grouped)
}
}
// MARK: - Model
private struct ModelSettings: View {
@EnvironmentObject private var app: AppState
@ObservedObject private var model: ModelManager
init() {
_model = ObservedObject(wrappedValue: AppState.shared.model)
}
var body: some View {
Form {
Section("On-device model") {
Picker("Model", selection: $model.selectedRepo) {
Text("Qwen2.5-3B (default)").tag(AppConfig.Model.defaultRepo)
Text("Qwen2.5-7B (more RAM)")
.tag(AppConfig.Model.largeRepo)
}
.disabled(!app.gate.entitlements.canUseLargeModel)
if !app.gate.entitlements.canUseLargeModel {
Label("The larger 7B model is available on paid plans.", systemImage: "lock")
.font(.caption).foregroundStyle(.secondary)
}
ModelDownloadControls().environmentObject(app)
}
Section {
Text("Downloaded once from Hugging Face, then everything runs offline. The model is never bundled in the app binary.")
.font(.caption).foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
// MARK: - Dictionary
private struct DictionarySettings: View {
@EnvironmentObject private var app: AppState
@ObservedObject private var dict: PersonalDictionary
@State private var newWord = ""
@State private var capMessage: String?
init() {
// Bound at render via environment; we re-observe the shared instance.
_dict = ObservedObject(wrappedValue: AppState.shared.dictionary)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
TextField("Add a word GrammaGem should never correct", text: $newWord)
.textFieldStyle(.roundedBorder)
.onSubmit(add)
Button("Add", action: add).disabled(newWord.trimmingCharacters(in: .whitespaces).isEmpty)
}
if let capMessage {
Label(capMessage, systemImage: "lock").font(.caption).foregroundStyle(.orange)
}
let cap = app.gate.entitlements.dictionaryCap
Text(cap == Int.max
? "\(dict.entries.count) entries · unlimited"
: "\(dict.entries.count) / \(cap) entries")
.font(.caption).foregroundStyle(.secondary)
List {
ForEach(dict.entries, id: \.self) { word in
HStack {
Text(word)
Spacer()
Button {
app.removeDictionaryEntry(word)
} label: { Image(systemName: "trash") }
.buttonStyle(.borderless)
}
}
}
.frame(minHeight: 220)
}
}
private func add() {
let decision = app.addDictionaryEntry(newWord)
switch decision {
case .allowed:
newWord = ""
capMessage = nil
case .denied(let message):
capMessage = message
}
}
}

View File

@ -0,0 +1,50 @@
import Foundation
/// Compile-time configuration. The Lemon Squeezy identifiers are hard-coded so
/// the licensing layer can *hard-verify* that a key belongs to GrammaGem (and to
/// the claimed tier) rather than to some other Lemon Squeezy product.
/// 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 supportEmail = "support@grammagem.app" // TODO(real-integration)
static let modelPortalURL = URL(string: "https://grammagem.app/license")! // TODO
/// Lemon Squeezy License API (app talks to it directly — no server of ours).
enum LemonSqueezy {
static let apiBase = URL(string: "https://api.lemonsqueezy.com/v1")!
static let expectedStoreID = "TODO_STORE_ID"
static let expectedProductID = "TODO_PRODUCT_ID"
/// One variant per tier; the variant's activation limit is the device cap.
static let variantID: [Tier: String] = [
.solo: "TODO_SOLO_VARIANT_ID",
.personal: "TODO_PERSONAL_VARIANT_ID",
.studio: "TODO_STUDIO_VARIANT_ID",
]
}
/// Local LLM weights, pulled from Hugging Face on first run (never bundled).
enum Model {
static let defaultRepo = "mlx-community/Qwen2.5-3B-Instruct-4bit" // Apache-2.0
static let largeRepo = "mlx-community/Qwen2.5-7B-Instruct-4bit" // optional, paid tiers
static let huggingFaceBase = "https://huggingface.co"
}
/// Free-tier limits + license validation cadence (see spec §5/§6).
enum Limits {
static let freeDailyAIActions = 10
static let freeDictionaryCap = 25
static let validationIntervalDays = 7
static let offlineGraceDays = 30
}
/// Default global hotkey: ⌘; (Carbon key code 41, Carbon cmdKey mask 256).
/// Stored as raw integers here to keep this file free of the Carbon import.
enum Hotkey {
static let defaultKeyCode: UInt32 = 41 // kVK_ANSI_Semicolon
static let defaultModifiers: UInt32 = 256 // cmdKey
// A second hotkey opens the "Ask" popover (default ⌘' = key 39).
static let askKeyCode: UInt32 = 39 // kVK_ANSI_Quote
static let askModifiers: UInt32 = 256
}
}

View File

@ -0,0 +1,13 @@
import Foundation
import os
/// Thin wrapper over `os.Logger`. Privacy-first: we never log user text.
enum Log {
private static let subsystem = AppConfig.bundleIdentifier
static let system = Logger(subsystem: subsystem, category: "system")
static let grammar = Logger(subsystem: subsystem, category: "grammar")
static let ai = Logger(subsystem: subsystem, category: "ai")
static let licensing = Logger(subsystem: subsystem, category: "licensing")
static let app = Logger(subsystem: subsystem, category: "app")
}

View File

@ -0,0 +1,112 @@
import Foundation
// MARK: - Plans & tiers
/// License tiers. `free` is the unlicensed default; paid tiers carry a device cap.
enum Tier: String, Codable, CaseIterable, Hashable {
case free
case solo
case personal
case studio
/// Number of Macs a license may be activated on (enforced via Lemon Squeezy
/// variant activation limits + our offline cap).
var deviceCap: Int {
switch self {
case .free: return 1
case .solo: return 1
case .personal: return 2
case .studio: return 4
}
}
var isPaid: Bool { self != .free }
var displayName: String {
switch self {
case .free: return "Free"
case .solo: return "Solo"
case .personal: return "Personal"
case .studio: return "Studio"
}
}
}
// MARK: - Grammar (Layer 1)
/// Categories Harper surfaces. Layer-1 corrections are deterministic + instant.
enum GrammarKind: String, Codable {
case spelling
case grammar
case punctuation
case phrasing
case repetition
case capitalization
}
/// A single grammar suggestion over a span of the input text.
struct Suggestion: Identifiable, Equatable {
let id = UUID()
/// UTF-16 offset + length, so it maps cleanly onto `NSRange` / AX ranges.
let location: Int
let length: Int
let original: String
let replacement: String
let kind: GrammarKind
let message: String
var range: NSRange { NSRange(location: location, length: length) }
}
// MARK: - AI actions (Layer 2)
/// The local-LLM-powered actions. All of these share the free daily counter.
enum AIAction: Equatable {
case rewriteClarity // clarity / conciseness
case adjustTone(Tone) // detect + shift tone
case rewrite // full paraphrase
case ask(String) // free-form instruction ("make formal", "shorten")
case translate(language: String)
case applyMode(WritingMode) // Mode-based rewrite
/// Whether this action is gated behind a paid license (free tier still gets
/// a limited daily allowance of these — see `FeatureGate`).
var isPaidAction: Bool {
switch self {
case .applyMode(let mode): return mode.isPaid
default: return true
}
}
}
enum Tone: String, Codable, CaseIterable {
case professional
case friendly
case confident
case academic
case punchy
}
// MARK: - Writing modes
/// A Writing Mode is just a named system-prompt preset for the local LLM.
struct WritingMode: Identifiable, Codable, Equatable {
var id: String
var name: String
var systemPrompt: String
/// Free tier ships only the `polish` mode; the rest are paid.
var isPaid: Bool
/// Optional length cap / auto-format hints surfaced in App-Aware settings.
var lengthCap: Int?
var autoFormat: String?
}
// MARK: - Engine results
/// Outcome of running the system-wide capture → engine → replace loop.
enum ProcessOutcome: Equatable {
case replaced(String) // text was rewritten in place
case noSelection // nothing was selected to act on
case blockedByEntitlement(String) // hit a paywall / daily limit
case failed(String) // capture/replace/engine error (message)
}

View File

@ -0,0 +1,32 @@
import Foundation
/// A single value object derived from license tier that every paid path checks.
/// Free is deliberately useful (full Harper grammar, system-wide) while the AI
/// superpowers + multi-device are the upgrade triggers (spec §5).
struct Entitlements: Equatable {
let tier: Tier
/// Layer-1 grammar is ALWAYS free + unlimited (the open-source core).
var unlimitedGrammar: Bool { true }
/// Layer-2 AI actions: unlimited when paid; capped per day on free.
var unlimitedAIActions: Bool { tier.isPaid }
var dailyAIActionCap: Int {
tier.isPaid ? Int.max : AppConfig.Limits.freeDailyAIActions
}
/// Writing Modes: free gets Polish only; paid gets all + custom.
var allModes: Bool { tier.isPaid }
/// Paid-only features.
var appAwareSwitching: Bool { tier.isPaid }
var snippets: Bool { tier.isPaid }
var canUseLargeModel: Bool { tier.isPaid }
/// Personal dictionary: 25 entries free, unlimited paid.
var dictionaryCap: Int {
tier.isPaid ? Int.max : AppConfig.Limits.freeDictionaryCap
}
var deviceCap: Int { tier.deviceCap }
}

View File

@ -0,0 +1,101 @@
import Foundation
/// The runtime gate every paid path goes through. Combines current `Entitlements`
/// (from the license) with a local daily counter for the free tier's AI allowance.
@MainActor
final class FeatureGate: ObservableObject {
enum Decision: Equatable {
case allowed
case denied(String)
}
private let license: LicenseManager
private let counter = DailyCounter()
/// Bumped to nudge SwiftUI when the counter changes (it's stored externally).
@Published private(set) var usageTick = 0
init(license: LicenseManager) {
self.license = license
}
var entitlements: Entitlements { license.entitlements }
// MARK: - AI gating (rewrite / tone / Ask / translate / paid Modes share this)
func authorizeAI(_ action: AIAction) -> Decision {
let ent = entitlements
// Paid Modes are unavailable on free (only Polish ships free).
if case .applyMode(let mode) = action, mode.isPaid, !ent.allModes {
return .denied("“\(mode.name)” mode is part of the lifetime upgrade.")
}
if ent.unlimitedAIActions { return .allowed }
guard remainingAIActionsToday > 0 else {
return .denied("You've used today's \(ent.dailyAIActionCap) free AI actions. Upgrade for unlimited.")
}
return .allowed
}
func recordAIActionUsed() {
guard !entitlements.unlimitedAIActions else { return }
counter.increment()
usageTick &+= 1
}
var aiActionsUsedToday: Int { counter.countToday() }
var remainingAIActionsToday: Int {
let ent = entitlements
if ent.unlimitedAIActions { return Int.max }
return max(0, ent.dailyAIActionCap - counter.countToday())
}
// MARK: - Other gates
func availableModes() -> [WritingMode] {
ModeRegistry.available(for: license.tier)
}
var appAwareEnabled: Bool { entitlements.appAwareSwitching }
var snippetsEnabled: Bool { entitlements.snippets }
/// Can the user add another personal-dictionary entry given the current count?
func canAddDictionaryEntry(currentCount: Int) -> Decision {
if currentCount < entitlements.dictionaryCap { return .allowed }
return .denied("Free dictionary holds \(entitlements.dictionaryCap) entries. Upgrade for unlimited.")
}
}
/// Free-tier AI action counter. Persists `{date, count}` in UserDefaults and
/// resets at local midnight (compares the stored local date string to today's).
final class DailyCounter {
private let key = "GrammaGem.aiActions.daily"
private let defaults = UserDefaults.standard
private static let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = .current // local midnight reset
return f
}()
private func today() -> String { Self.formatter.string(from: Date()) }
func countToday() -> Int {
guard let stored = defaults.dictionary(forKey: key),
let date = stored["date"] as? String, date == today()
else { return 0 }
return stored["count"] as? Int ?? 0
}
func increment() {
let next = countToday() + 1
defaults.set(["date": today(), "count": next], forKey: key)
}
func reset() { defaults.removeObject(forKey: key) }
}

View File

@ -0,0 +1,30 @@
import Foundation
/// Layer 1 — deterministic, instant, offline grammar checking.
///
/// In production this is backed by **Harper** (Rust, Apache-2.0) via a C-FFI
/// static lib (see `harper-ffi/`) or a bundled `harper-ls` subprocess. The
/// protocol keeps the rest of the app independent of that integration.
protocol GrammarEngine: AnyObject {
/// Return suggestions over `text` (non-destructive; UI draws underlines).
func check(_ text: String) -> [Suggestion]
/// Apply every suggestion to produce a corrected string (used by the
/// "fix in place" hotkey path).
func correct(_ text: String) -> String
/// Words the engine should never flag (names, brand terms, jargon).
var ignoreList: Set<String> { get set }
}
extension GrammarEngine {
/// Default correction: apply suggestions right-to-left so offsets stay valid.
func correct(_ text: String) -> String {
let ns = NSMutableString(string: text)
for s in check(text).sorted(by: { $0.location > $1.location }) {
guard s.location + s.length <= ns.length else { continue }
ns.replaceCharacters(in: s.range, with: s.replacement)
}
return ns as String
}
}

View File

@ -0,0 +1,75 @@
import Foundation
/// STUB standing in for the real Harper core.
///
/// TODO(real-integration): replace the rule set below with calls into the Harper
/// Rust crate over C-FFI (`harper_check(text) -> spans`). This stub implements a
/// handful of Harper-style deterministic rules so the system loop, live
/// underlines, and tests have something real to exercise. It runs in well under
/// 10ms and is fully offline — matching Harper's performance profile.
final class HarperEngine: GrammarEngine {
var ignoreList: Set<String> = []
func check(_ text: String) -> [Suggestion] {
var out: [Suggestion] = []
let ns = text as NSString
// Rule 1 — repeated word ("the the" -> "the").
out += matches(in: text, pattern: #"\b(\w+)\s+\1\b"#) { match in
let full = ns.substring(with: match.range)
let firstWord = ns.substring(with: match.range(at: 1))
guard !ignoreList.contains(firstWord.lowercased()) else { return nil }
return Suggestion(
location: match.range.location, length: match.range.length,
original: full, replacement: firstWord,
kind: .repetition, message: "Repeated word")
}
// Rule 2 — lowercase standalone "i" -> "I".
out += matches(in: text, pattern: #"(?<=\s|^)i(?=\s|$|')"#) { match in
Suggestion(
location: match.range.location, length: match.range.length,
original: "i", replacement: "I",
kind: .capitalization, message: "Capitalize the pronoun “I”")
}
// Rule 3 — a few common confusions / typos.
let confusions: [(String, String, GrammarKind, String)] = [
(#"\btheir\b(?=\s+(any|are|is|was|were)\b)"#, "there", .grammar, "Did you mean “there”?"),
(#"\bteh\b"#, "the", .spelling, "Spelling"),
(#"\brecieve\b"#, "receive", .spelling, "Spelling (i before e)"),
(#"\bcheckin\b"#, "check in", .phrasing, "“check in” is two words"),
(#"\blmk\b"#, "let me know", .phrasing, "Expand abbreviation"),
(#"(?<=\s|^)u(?=\s|$)"#, "you", .phrasing, "Expand “u”"),
]
for (pattern, replacement, kind, message) in confusions {
out += matches(in: text, pattern: pattern) { match in
let original = ns.substring(with: match.range)
guard !ignoreList.contains(original.lowercased()) else { return nil }
return Suggestion(
location: match.range.location, length: match.range.length,
original: original, replacement: replacement,
kind: kind, message: message)
}
}
// Rule 4 — sentence should start uppercase (very light heuristic).
// (Left to the real Harper integration; intentionally omitted to avoid
// false positives in this stub.)
return out.sorted { $0.location < $1.location }
}
// MARK: - Regex helper
private func matches(
in text: String, pattern: String,
_ make: (NSTextCheckingResult) -> Suggestion?
) -> [Suggestion] {
guard let re = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
return []
}
let full = NSRange(text.startIndex..., in: text)
return re.matches(in: text, range: full).compactMap(make)
}
}

View File

@ -0,0 +1,37 @@
import Foundation
/// The user's personal dictionary — names, brand terms, jargon GrammaGem should
/// never "correct". Feeds Harper's ignore-list and the LLM context. Capped at 25
/// entries on free, unlimited on paid (enforced by `FeatureGate`, not here).
@MainActor
final class PersonalDictionary: ObservableObject {
@Published private(set) var entries: [String]
private let key = "GrammaGem.personalDictionary"
private let defaults = UserDefaults.standard
init() {
entries = defaults.stringArray(forKey: key) ?? []
}
func contains(_ word: String) -> Bool {
entries.contains { $0.caseInsensitiveCompare(word) == .orderedSame }
}
/// Append a trimmed, de-duplicated entry. Cap enforcement happens upstream.
func add(_ word: String) {
let w = word.trimmingCharacters(in: .whitespacesAndNewlines)
guard !w.isEmpty, !contains(w) else { return }
entries.append(w)
persist()
}
func remove(_ word: String) {
entries.removeAll { $0.caseInsensitiveCompare(word) == .orderedSame }
persist()
}
private func persist() {
defaults.set(entries, forKey: key)
}
}

View File

@ -0,0 +1,42 @@
import Foundation
import IOKit
import CryptoKit
/// Derives a stable, privacy-preserving device identifier.
///
/// Lemon Squeezy has no hardware fingerprinting of its own, so we build one
/// (spec §6): take the hardware `IOPlatformUUID`, SHA-256 it, and pass the hash
/// as the `instance_name` on activation. Same Mac → same instance → a reinstall
/// doesn't consume a second device slot. The raw UUID never leaves the device;
/// only its hash is sent.
enum DeviceFingerprint {
/// SHA-256 of the hardware platform UUID, hex-encoded. Falls back to a
/// persisted random ID if IOKit is somehow unavailable.
static func stableID() -> String {
if let uuid = platformUUID() {
let digest = SHA256.hash(data: Data(uuid.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
return fallbackID()
}
private static func platformUUID() -> String? {
let service = IOServiceGetMatchingService(
kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice"))
guard service != 0 else { return nil }
defer { IOObjectRelease(service) }
guard let cf = IORegistryEntryCreateCFProperty(
service, kIOPlatformUUIDKey as CFString, kCFAllocatorDefault, 0)
else { return nil }
return cf.takeRetainedValue() as? String
}
private static func fallbackID() -> String {
let key = "GrammaGem.fallbackDeviceID"
if let existing = UserDefaults.standard.string(forKey: key) { return existing }
let id = UUID().uuidString
UserDefaults.standard.set(id, forKey: key)
return id
}
}

View File

@ -0,0 +1,58 @@
import Foundation
import Security
/// Minimal Keychain wrapper for the license record. The license key + instance
/// id + last-validated timestamp live here (never in plain UserDefaults).
enum Keychain {
private static let service = AppConfig.bundleIdentifier
static func set(_ data: Data, account: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(query as CFDictionary)
var add = query
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let status = SecItemAdd(add as CFDictionary, nil)
if status != errSecSuccess {
Log.licensing.error("Keychain set failed: \(status)")
}
}
static func get(account: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
}
static func delete(account: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(query as CFDictionary)
}
// Codable convenience.
static func setObject<T: Encodable>(_ value: T, account: String) {
if let data = try? JSONEncoder().encode(value) { set(data, account: account) }
}
static func getObject<T: Decodable>(_ type: T.Type, account: String) -> T? {
guard let data = get(account: account) else { return nil }
return try? JSONDecoder().decode(type, from: data)
}
}

View File

@ -0,0 +1,125 @@
import Foundation
/// Talks to the **Lemon Squeezy License API** directly from the app — no server
/// of ours, no database, no per-user cost (spec §6). Exposes activate / validate
/// / deactivate and hard-verifies that a key belongs to GrammaGem + the claimed tier.
final class LemonSqueezyClient {
private let session: URLSession
private let base = AppConfig.LemonSqueezy.apiBase
init(session: URLSession = .shared) {
self.session = session
}
// MARK: - Endpoints
func activate(key: String, instanceName: String) async throws -> (instanceID: String, tier: Tier) {
let res: LSActivateResponse = try await post(
"licenses/activate",
params: ["license_key": key, "instance_name": instanceName])
if let err = res.error { throw mapServerError(err) }
guard res.activated, let instance = res.instance else {
throw LicenseError.serverMessage("Activation failed.")
}
let tier = try verifyAndResolveTier(meta: res.meta, licenseKey: res.license_key)
return (instance.id, tier)
}
func validate(key: String, instanceID: String?) async throws -> (valid: Bool, tier: Tier) {
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)
}
@discardableResult
func deactivate(key: String, instanceID: String) async throws -> Bool {
let res: LSDeactivateResponse = try await post(
"licenses/deactivate",
params: ["license_key": key, "instance_id": instanceID])
if let err = res.error { throw mapServerError(err) }
return res.deactivated
}
// MARK: - Hard verification (spec §6)
/// Confirm the key is for OUR store/product, then resolve which tier its
/// variant corresponds to. Prevents a key from another LS product unlocking GrammaGem.
private func verifyAndResolveTier(meta: LSMeta?, licenseKey: LSLicenseKey?) throws -> Tier {
guard let meta else { throw LicenseError.wrongProduct }
// Store + product check. If AppConfig still holds placeholder (non-numeric)
// IDs, we log and skip the check so the stub flow runs — TODO(real-integration):
// once real numeric IDs are set, this becomes a hard gate.
if let expectedStore = Int(AppConfig.LemonSqueezy.expectedStoreID),
let expectedProduct = Int(AppConfig.LemonSqueezy.expectedProductID) {
guard meta.store_id == expectedStore, meta.product_id == expectedProduct else {
throw LicenseError.wrongProduct
}
} else {
Log.licensing.warning("Skipping store/product hard-verify — placeholder IDs in AppConfig.")
}
// Resolve tier from the variant id (reverse-lookup the configured map).
if let variantID = meta.variant_id {
for (tier, idString) in AppConfig.LemonSqueezy.variantID {
if Int(idString) == variantID { return tier }
}
}
// Fall back to whatever the variant name hints at, else Solo.
switch meta.variant_name?.lowercased() {
case let v? where v.contains("studio"): return .studio
case let v? where v.contains("personal"): return .personal
default: return .solo
}
}
private func mapServerError(_ message: String) -> LicenseError {
let m = message.lowercased()
if m.contains("activation limit") {
return .activationLimitReached(0)
}
if m.contains("not found") || m.contains("invalid") {
return .invalidKey
}
return .serverMessage(message)
}
// MARK: - Transport
private func post<T: Decodable>(_ path: String, params: [String: String]) async throws -> T {
var request = URLRequest(url: base.appendingPathComponent(path))
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = formEncode(params).data(using: .utf8)
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw LicenseError.network("No HTTP response")
}
// LS returns 200 + {activated/valid:false} for soft failures, and 4xx
// with a JSON `error`. Try to decode the body either way.
if let decoded = try? JSONDecoder().decode(T.self, from: data) {
return decoded
}
throw LicenseError.network("Unexpected response (\(http.statusCode))")
} catch let e as LicenseError {
throw e
} catch {
throw LicenseError.network(error.localizedDescription)
}
}
private func formEncode(_ params: [String: String]) -> String {
params.map { key, value in
let v = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value
return "\(key)=\(v)"
}.joined(separator: "&")
}
}

View File

@ -0,0 +1,100 @@
import Foundation
/// Owns license state and maps it to an `Entitlements` value the rest of the app
/// gates on. Implements the two gaps Lemon Squeezy leaves to us (spec §6):
/// device fingerprinting (via `DeviceFingerprint`) and **offline grace**.
@MainActor
final class LicenseManager: ObservableObject {
@Published private(set) var record: LicenseRecord?
@Published private(set) var lastError: String?
@Published private(set) var isWorking = false
private let client: LemonSqueezyClient
private let account = "license"
init(client: LemonSqueezyClient = LemonSqueezyClient()) {
self.client = client
self.record = Keychain.getObject(LicenseRecord.self, account: account)
}
/// Effective tier *right now*, honoring the 30-day offline grace: if the
/// device hasn't validated within the window, downgrade to Free rather than
/// hard-lock (writing features never need the network anyway).
var tier: Tier {
guard let record else { return .free }
let grace = TimeInterval(AppConfig.Limits.offlineGraceDays * 86_400)
if Date().timeIntervalSince(record.lastValidated) > grace {
return .free
}
return record.tier
}
var isLicensed: Bool { tier.isPaid }
var entitlements: Entitlements { Entitlements(tier: tier) }
// MARK: - Flows
/// Activate a pasted key against this device.
func activate(key: String) async {
let key = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { lastError = "Enter a license key."; return }
isWorking = true; lastError = nil
defer { isWorking = false }
do {
let instanceName = DeviceFingerprint.stableID()
let (instanceID, tier) = try await client.activate(key: key, instanceName: instanceName)
let rec = LicenseRecord(
key: key, instanceID: instanceID, tier: tier,
lastValidated: Date(), activatedDevices: tier.deviceCap)
Keychain.setObject(rec, account: account)
record = rec
Log.licensing.info("Activated tier \(tier.rawValue, privacy: .public)")
} catch {
lastError = error.localizedDescription
}
}
/// On launch + every ~7 days. Network failures are non-fatal (offline grace).
func validateIfNeeded(force: Bool = false) async {
guard var rec = record else { return }
let interval = TimeInterval(AppConfig.Limits.validationIntervalDays * 86_400)
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 {
rec.lastValidated = Date()
rec.tier = tier
Keychain.setObject(rec, account: account)
record = rec
} else {
// Explicitly invalid (refunded / disabled) → drop the license.
Log.licensing.notice("License reported invalid by server; clearing.")
clearLocal()
}
} catch {
// Offline / transient: keep the cached license under the grace window.
Log.licensing.notice("Validation deferred (offline). Cached license stands under grace.")
}
}
/// Free a device slot so the license can move to another Mac.
func deactivateThisDevice() async {
guard let rec = record else { return }
isWorking = true; lastError = nil
defer { isWorking = false }
do {
_ = try await client.deactivate(key: rec.key, instanceID: rec.instanceID)
clearLocal()
} catch {
lastError = error.localizedDescription
}
}
func clearLocal() {
Keychain.delete(account: account)
record = nil
}
}

View File

@ -0,0 +1,70 @@
import Foundation
/// The locally-stored license record (Keychain-persisted).
struct LicenseRecord: Codable, Equatable {
var key: String
var instanceID: String // Lemon Squeezy activation instance id
var tier: Tier
var lastValidated: Date
var activatedDevices: Int
}
// MARK: - Lemon Squeezy API response shapes (subset we rely on)
struct LSActivateResponse: Codable {
let activated: Bool
let instance: LSInstance?
let license_key: LSLicenseKey?
let meta: LSMeta?
let error: String?
}
struct LSValidateResponse: Codable {
let valid: Bool
let license_key: LSLicenseKey?
let meta: LSMeta?
let error: String?
}
struct LSDeactivateResponse: Codable {
let deactivated: Bool
let error: String?
}
struct LSInstance: Codable {
let id: String
let name: String
}
struct LSLicenseKey: Codable {
let status: String // "active", "expired", "disabled", …
let activation_limit: Int?
let activation_usage: Int?
}
/// `meta` carries the store/product/variant identity we *hard-verify* against
/// AppConfig so a key from another Lemon Squeezy product can't unlock GrammaGem.
struct LSMeta: Codable {
let store_id: Int?
let product_id: Int?
let variant_id: Int?
let variant_name: String?
}
enum LicenseError: LocalizedError, Equatable {
case network(String)
case invalidKey
case wrongProduct
case activationLimitReached(Int)
case serverMessage(String)
var errorDescription: String? {
switch self {
case .network(let m): return "Couldn't reach the license server: \(m)"
case .invalidKey: return "That license key isn't valid."
case .wrongProduct: return "That key belongs to a different product."
case .activationLimitReached(let n): return "This plan is active on \(n) Macs — deactivate one or upgrade."
case .serverMessage(let m): return m
}
}
}

View File

@ -0,0 +1,26 @@
import Foundation
import AppKit
/// Reports the frontmost application so App-Aware mode switching can pick the
/// right Writing Mode. Pure local introspection — no network, no logging of content.
@MainActor
final class AppDetector {
struct FrontApp: Equatable {
let bundleID: String
let name: String
}
/// The app currently in the foreground (the one the hotkey will act on).
func frontmost() -> FrontApp? {
guard let app = NSWorkspace.shared.frontmostApplication else { return nil }
return FrontApp(
bundleID: app.bundleIdentifier ?? "unknown",
name: app.localizedName ?? "App")
}
/// The Writing Mode App-Aware would auto-apply for the current frontmost app.
func suggestedMode() -> WritingMode? {
guard let front = frontmost() else { return nil }
return ModeRegistry.mode(forBundleID: front.bundleID)
}
}

View File

@ -0,0 +1,82 @@
import Foundation
import Carbon.HIToolbox
/// Registers system-wide global hotkeys via Carbon `RegisterEventHotKey`.
///
/// This is the no-dependency path. The spec lists `KeyboardShortcuts`
/// (Sindre Sorhus) for a nicer recorder UI; you can swap this implementation
/// for that package without touching callers. User-configurable (defaults ⌘; and ⌘').
final class HotkeyManager {
/// Logical actions a hotkey can fire.
enum Action { case fix, ask }
var onAction: ((Action) -> Void)?
private var refs: [EventHotKeyRef?] = []
private var handler: EventHandlerRef?
private var idToAction: [UInt32: Action] = [:]
init() {
installHandler()
}
deinit {
unregisterAll()
if let handler { RemoveEventHandler(handler) }
}
/// Register the two product hotkeys. Call again to re-register after the
/// user changes a shortcut.
func registerDefaults() {
register(id: 1, keyCode: AppConfig.Hotkey.defaultKeyCode,
modifiers: AppConfig.Hotkey.defaultModifiers, action: .fix)
register(id: 2, keyCode: AppConfig.Hotkey.askKeyCode,
modifiers: AppConfig.Hotkey.askModifiers, action: .ask)
}
func register(id: UInt32, keyCode: UInt32, modifiers: UInt32, action: Action) {
idToAction[id] = action
let hotKeyID = EventHotKeyID(signature: OSType(0x47474D31), id: id) // 'GGM1'
var ref: EventHotKeyRef?
let status = RegisterEventHotKey(
keyCode, modifiers, hotKeyID,
GetEventDispatcherTarget(), 0, &ref)
if status == noErr {
refs.append(ref)
} else {
Log.system.error("RegisterEventHotKey failed: \(status)")
}
}
func unregisterAll() {
for ref in refs where ref != nil { UnregisterEventHotKey(ref) }
refs.removeAll()
}
// MARK: - Carbon event plumbing
private func installHandler() {
var spec = EventTypeSpec(
eventClass: OSType(kEventClassKeyboard),
eventKind: UInt32(kEventHotKeyPressed))
let selfPtr = Unmanaged.passUnretained(self).toOpaque()
InstallEventHandler(
GetEventDispatcherTarget(),
{ (_, eventRef, userData) -> OSStatus in
guard let eventRef, let userData else { return noErr }
var hkID = EventHotKeyID()
let err = GetEventParameter(
eventRef, EventParamName(kEventParamDirectObject),
EventParamType(typeEventHotKeyID), nil,
MemoryLayout<EventHotKeyID>.size, nil, &hkID)
guard err == noErr else { return err }
let mgr = Unmanaged<HotkeyManager>.fromOpaque(userData).takeUnretainedValue()
if let action = mgr.idToAction[hkID.id] {
DispatchQueue.main.async { mgr.onAction?(action) }
}
return noErr
},
1, &spec, selfPtr, &handler)
}
}

View File

@ -0,0 +1,54 @@
import Foundation
import AppKit
import ApplicationServices
/// Tracks and requests the macOS permissions GrammaGem needs to read & replace
/// text system-wide: **Accessibility** (required) and, for the synthetic-keystroke
/// clipboard fallback, **Input Monitoring** may also be prompted by the system.
@MainActor
final class Permissions: ObservableObject {
@Published private(set) var accessibilityTrusted: Bool = false
init() {
refresh()
}
/// True when the app is trusted for the Accessibility API.
func refresh() {
accessibilityTrusted = AXIsProcessTrusted()
}
/// Prompt for Accessibility (shows the system dialog the first time).
func requestAccessibility() {
let key = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
let options = [key: true] as CFDictionary
accessibilityTrusted = AXIsProcessTrustedWithOptions(options)
}
/// Deep-link to the relevant System Settings pane.
func openAccessibilitySettings() {
open("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
}
func openInputMonitoringSettings() {
open("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent")
}
private func open(_ urlString: String) {
guard let url = URL(string: urlString) else { return }
NSWorkspace.shared.open(url)
}
/// Poll until the user grants Accessibility (used by onboarding to advance
/// automatically once permission flips on). Cancels when `accessibilityTrusted`.
func startPollingUntilGranted(interval: TimeInterval = 1.0) {
guard !accessibilityTrusted else { return }
Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] timer in
Task { @MainActor in
guard let self else { timer.invalidate(); return }
self.refresh()
if self.accessibilityTrusted { timer.invalidate() }
}
}
}
}

View File

@ -0,0 +1,136 @@
import Foundation
import AppKit
import ApplicationServices
import Carbon.HIToolbox
/// Reads the user's selected text from the frontmost app and writes a corrected
/// value back — the mechanism that makes GrammaGem work *everywhere*.
///
/// Two paths, mirroring the spec (§4):
/// 1. **Accessibility API** (preferred): read/write `kAXSelectedTextAttribute`
/// on the focused element. Works in most native + many Electron apps.
/// 2. **Clipboard fallback** (universal): synthesize ⌘C, read the pasteboard,
/// write the result back, synthesize ⌘V, then restore the prior clipboard.
final class TextCapture {
enum Method { case accessibility, clipboard }
struct Capture {
let text: String
let method: Method
/// Held so the AX path can set the new value on the same element.
let focusedElement: AXUIElement?
}
enum CaptureError: LocalizedError {
case noSelection
case notPermitted
var errorDescription: String? {
switch self {
case .noSelection: return "Select some text first, then press the hotkey."
case .notPermitted: return "GrammaGem needs Accessibility permission to read and replace text."
}
}
}
private let pasteboard = NSPasteboard.general
// MARK: - Capture
/// Try AX first; on empty/unavailable, fall back to the clipboard path.
func capture() throws -> Capture {
if let ax = captureViaAccessibility() {
return ax
}
if let clip = try captureViaClipboard() {
return clip
}
throw CaptureError.noSelection
}
private func captureViaAccessibility() -> Capture? {
guard AXIsProcessTrusted() else { return nil }
let systemWide = AXUIElementCreateSystemWide()
var focusedRef: CFTypeRef?
guard AXUIElementCopyAttributeValue(
systemWide, kAXFocusedUIElementAttribute as CFString, &focusedRef) == .success,
let focusedRef
else { return nil }
// Force-cast through CFTypeRef to AXUIElement (same CF type).
let focused = focusedRef as! AXUIElement
var valueRef: CFTypeRef?
guard AXUIElementCopyAttributeValue(
focused, kAXSelectedTextAttribute as CFString, &valueRef) == .success,
let text = valueRef as? String, !text.isEmpty
else { return nil }
return Capture(text: text, method: .accessibility, focusedElement: focused)
}
private func captureViaClipboard() throws -> Capture? {
guard AXIsProcessTrusted() else { throw CaptureError.notPermitted }
let saved = pasteboard.string(forType: .string)
let beforeChange = pasteboard.changeCount
sendCommandKey(CGKeyCode(kVK_ANSI_C))
// Give the frontmost app a moment to put the selection on the pasteboard.
Thread.sleep(forTimeInterval: 0.12)
guard pasteboard.changeCount != beforeChange,
let copied = pasteboard.string(forType: .string), !copied.isEmpty
else {
// Restore and report nothing selected.
if let saved { writeClipboard(saved) }
return nil
}
// Stash the saved clipboard on the capture so replace() can restore it.
savedClipboard = saved
return Capture(text: copied, method: .clipboard, focusedElement: nil)
}
// MARK: - Replace
@discardableResult
func replace(_ newText: String, capture: Capture) -> Bool {
switch capture.method {
case .accessibility:
guard let element = capture.focusedElement else { return false }
let status = AXUIElementSetAttributeValue(
element, kAXSelectedTextAttribute as CFString, newText as CFTypeRef)
return status == .success
case .clipboard:
writeClipboard(newText)
sendCommandKey(CGKeyCode(kVK_ANSI_V))
Thread.sleep(forTimeInterval: 0.10)
// Restore the user's previous clipboard contents.
if let saved = savedClipboard {
Thread.sleep(forTimeInterval: 0.05)
writeClipboard(saved)
}
savedClipboard = nil
return true
}
}
// MARK: - Helpers
private var savedClipboard: String?
private func writeClipboard(_ text: String) {
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
}
/// Synthesize a ⌘<key> keystroke via CGEvent (needs Accessibility / Input Monitoring).
private func sendCommandKey(_ key: CGKeyCode) {
let source = CGEventSource(stateID: .combinedSessionState)
let down = CGEvent(keyboardEventSource: source, virtualKey: key, keyDown: true)
let up = CGEvent(keyboardEventSource: source, virtualKey: key, keyDown: false)
down?.flags = .maskCommand
up?.flags = .maskCommand
down?.post(tap: .cghidEventTap)
up?.post(tap: .cghidEventTap)
}
}

View File

@ -0,0 +1,109 @@
import Foundation
/// Orchestrates the end-to-end system-wide loop:
/// capture selected text → gate by entitlement → run an engine → write it back.
///
/// `fix` uses Layer 1 (Harper grammar — free, unlimited, instant).
/// `ask` and Mode rewrites use Layer 2 (local LLM — gated + counted on free tier).
@MainActor
final class TextReplacementCoordinator {
private let capture: TextCapture
private let grammar: GrammarEngine
private let ai: AIEngine
private let gate: FeatureGate
private let detector: AppDetector
init(capture: TextCapture, grammar: GrammarEngine, ai: AIEngine,
gate: FeatureGate, detector: AppDetector) {
self.capture = capture
self.grammar = grammar
self.ai = ai
self.gate = gate
self.detector = detector
}
/// The "fix" hotkey — instant grammar correction (Layer 1, always free).
func handleFix() async -> ProcessOutcome {
let cap: TextCapture.Capture
do {
cap = try await captureOffMain()
} catch TextCapture.CaptureError.noSelection {
return .noSelection
} catch {
return .failed(error.localizedDescription)
}
let corrected = grammar.correct(cap.text)
guard corrected != cap.text else { return .replaced(corrected) }
let ok = await replaceOffMain(corrected, cap)
return ok ? .replaced(corrected) : .failed("Couldn't write the correction back.")
}
/// The "Ask" hotkey/popover — a local-LLM instruction over the selection.
func handleAsk(_ instruction: String) async -> ProcessOutcome {
let action = AIAction.ask(instruction)
if case .denied(let reason) = gate.authorizeAI(action) {
return .blockedByEntitlement(reason)
}
guard ai.isReady else { return .failed(AIEngineError.modelNotReady.localizedDescription) }
let cap: TextCapture.Capture
do { cap = try await captureOffMain() } catch { return .failed(error.localizedDescription) }
do {
let result = try await ai.run(action, on: cap.text)
gate.recordAIActionUsed()
let ok = await replaceOffMain(result, cap)
return ok ? .replaced(result) : .failed("Couldn't write the result back.")
} catch {
return .failed(error.localizedDescription)
}
}
/// Apply a Writing Mode rewrite to the selection (paid; free tier only Polish).
func handleApplyMode(_ mode: WritingMode) async -> ProcessOutcome {
let action = AIAction.applyMode(mode)
if case .denied(let reason) = gate.authorizeAI(action) {
return .blockedByEntitlement(reason)
}
guard ai.isReady else { return .failed(AIEngineError.modelNotReady.localizedDescription) }
let cap: TextCapture.Capture
do { cap = try await captureOffMain() } catch { return .failed(error.localizedDescription) }
do {
let result = try await ai.run(action, on: cap.text)
gate.recordAIActionUsed()
let ok = await replaceOffMain(result, cap)
return ok ? .replaced(result) : .failed("Couldn't write the result back.")
} catch {
return .failed(error.localizedDescription)
}
}
/// Milestone-1 proof: capture → UPPERCASE → replace, end-to-end, no engine.
func handleDebugUppercase() async -> ProcessOutcome {
let cap: TextCapture.Capture
do { cap = try await captureOffMain() } catch { return .failed(error.localizedDescription) }
let out = cap.text.uppercased()
let ok = await replaceOffMain(out, cap)
return ok ? .replaced(out) : .failed("Replace failed.")
}
// MARK: - Off-main wrappers (capture/replace block on synthetic keystrokes)
private func captureOffMain() async throws -> TextCapture.Capture {
let capture = self.capture
return try await Task.detached(priority: .userInitiated) {
try capture.capture()
}.value
}
private func replaceOffMain(_ text: String, _ cap: TextCapture.Capture) async -> Bool {
let capture = self.capture
return await Task.detached(priority: .userInitiated) {
capture.replace(text, capture: cap)
}.value
}
}

View File

@ -0,0 +1,78 @@
import XCTest
@testable import GrammaGem
final class GrammaGemTests: XCTestCase {
// MARK: - Tiers / entitlements
func testDeviceCapsMatchSpec() {
XCTAssertEqual(Tier.solo.deviceCap, 1)
XCTAssertEqual(Tier.personal.deviceCap, 2)
XCTAssertEqual(Tier.studio.deviceCap, 4)
XCTAssertEqual(Tier.free.deviceCap, 1)
}
func testFreeEntitlementsAreLimited() {
let free = Entitlements(tier: .free)
XCTAssertFalse(free.unlimitedAIActions)
XCTAssertEqual(free.dailyAIActionCap, AppConfig.Limits.freeDailyAIActions)
XCTAssertEqual(free.dictionaryCap, AppConfig.Limits.freeDictionaryCap)
XCTAssertFalse(free.appAwareSwitching)
XCTAssertFalse(free.allModes)
XCTAssertTrue(free.unlimitedGrammar) // grammar is always free
}
func testPaidEntitlementsUnlockEverything() {
let paid = Entitlements(tier: .personal)
XCTAssertTrue(paid.unlimitedAIActions)
XCTAssertEqual(paid.dailyAIActionCap, Int.max)
XCTAssertEqual(paid.dictionaryCap, Int.max)
XCTAssertTrue(paid.appAwareSwitching)
XCTAssertTrue(paid.allModes)
}
// MARK: - Grammar (Harper stub) rules
func testHarperFixesRepeatedWordAndCasing() {
let harper = HarperEngine()
let input = "hey i wanted to checkin about the the proposal"
let fixed = harper.correct(input)
XCTAssertFalse(fixed.contains("the the"), "repeated word should be collapsed")
XCTAssertTrue(fixed.contains(" I "), "lowercase pronoun i should be capitalized")
XCTAssertTrue(fixed.contains("check in"), "checkin should split into two words")
}
func testIgnoreListSuppressesCorrection() {
let harper = HarperEngine()
harper.ignoreList = ["teh"] // pretend "teh" is an intentional brand term
let suggestions = harper.check("teh launch")
XCTAssertFalse(suggestions.contains { $0.original.lowercased() == "teh" })
}
// MARK: - Modes
func testFreeTierGetsOnlyPolishMode() {
let free = ModeRegistry.available(for: .free)
XCTAssertEqual(free.map(\.id), ["polish"])
let paid = ModeRegistry.available(for: .studio)
XCTAssertGreaterThan(paid.count, 1)
XCTAssertTrue(paid.contains { $0.id == "email" })
}
func testAppAwareModeMapping() {
XCTAssertEqual(ModeRegistry.mode(forBundleID: "com.apple.mail")?.id, "email")
XCTAssertEqual(ModeRegistry.mode(forBundleID: "com.microsoft.VSCode")?.id, "code")
XCTAssertNil(ModeRegistry.mode(forBundleID: "com.unknown.app"))
}
// MARK: - Device fingerprint
func testDeviceFingerprintIsStableAndHashed() {
let a = DeviceFingerprint.stableID()
let b = DeviceFingerprint.stableID()
XCTAssertEqual(a, b, "fingerprint must be stable across calls")
XCTAssertFalse(a.isEmpty)
// SHA-256 hex is 64 chars (unless the random fallback path is hit).
XCTAssertTrue(a.count == 64 || a.count == UUID().uuidString.count)
}
}

47
harper-ffi/README.md Normal file
View File

@ -0,0 +1,47 @@
# harper-ffi
A thin Rust static library that exposes the **Harper** grammar core
(Apache-2.0) to Swift over a C-FFI. This directory is a placeholder for the real
integration (spec §1, §2, §8).
The Swift side talks to this through the `GrammarEngine` protocol; today
[`HarperEngine.swift`](../Sources/GrammaGem/Grammar/HarperEngine.swift) ships a
small pure-Swift rule stub so the system loop + tests run with zero native deps.
## Planned shape
```
harper-ffi/
├─ Cargo.toml # crate-type = ["staticlib"]; depends on `harper-core`
├─ src/lib.rs # #[no_mangle] extern "C" fns over harper-core
└─ include/harper.h # C header consumed by Swift (module map / bridging)
```
Sketch of the FFI surface:
```rust
// src/lib.rs
#[no_mangle]
pub extern "C" fn harper_check(text: *const c_char,
out_json: *mut *mut c_char) -> usize { /* … */ }
#[no_mangle]
pub extern "C" fn harper_free(ptr: *mut c_char) { /* … */ }
```
## Wiring into SwiftPM
1. Build the static lib: `cargo build --release` → `libharper_ffi.a`.
2. Add a `systemLibrary`/binary target (or `linkerSettings`/`unsafeFlags`) in
`Package.swift` pointing at the `.a` + the header's module map.
3. Replace the rule set in `HarperEngine.check(_:)` with calls to
`harper_check`, decoding the returned span JSON into `[Suggestion]`.
> Alternative considered: bundle `harper-ls` and talk to it as a subprocess over
> LSP. The FFI route is leaner (no process, no JSON-RPC) and is preferred.
## Why Harper (not LanguageTool)
Harper is ~1/50th the memory of LanguageTool and needs no multi-GB n-gram data,
so it's the right *embeddable* core. LanguageTool stays an optional power-user
backend, never the default (spec §2).

26
scripts/build.sh Executable file
View File

@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Build a runnable GrammaGem.app bundle from the SwiftPM executable.
# Usage: ./scripts/build.sh (run from the mac/ directory)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
CONFIG="release"
APP="GrammaGem.app"
BUILD_DIR=".build/${CONFIG}"
DIST="dist"
echo "==> Compiling (${CONFIG})"
swift build -c "${CONFIG}"
echo "==> Assembling ${APP}"
rm -rf "${DIST}/${APP}"
mkdir -p "${DIST}/${APP}/Contents/MacOS" "${DIST}/${APP}/Contents/Resources"
cp "${BUILD_DIR}/GrammaGem" "${DIST}/${APP}/Contents/MacOS/GrammaGem"
cp "AppSupport/Info.plist" "${DIST}/${APP}/Contents/Info.plist"
# cp "AppSupport/AppIcon.icns" "${DIST}/${APP}/Contents/Resources/" # TODO: add an icon
echo "==> Built ${DIST}/${APP}"
echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)"

32
scripts/release.sh Executable file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Package a signed build for distribution and update the Sparkle appcast hosted
# on GitHub Releases (zero hosting cost). Run after sign-notarize.sh.
#
# This is a documented scaffold — wire it to your Sparkle EdDSA key + repo.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
APP="$ROOT/dist/GrammaGem.app"
VERSION="${1:?Usage: release.sh <version> e.g. 0.1.0}"
DMG="$ROOT/dist/GrammaGem-$VERSION.dmg"
echo "▸ Creating DMG for $VERSION…"
# hdiutil create -volname "GrammaGem" -srcfolder "$APP" -ov -format UDZO "$DMG" # TODO: enable
echo " (DMG creation stubbed — uncomment hdiutil line)"
cat <<'NOTE'
▸ Sparkle appcast (TODO):
1. Sign the update: ./bin/sign_update dist/GrammaGem-<ver>.dmg
(Sparkle's sign_update tool, using your EdDSA private key)
2. Add an <item> to appcast.xml with the version, URL (GitHub Releases asset),
length, and sparkle:edSignature.
3. Upload the DMG + appcast.xml to a GitHub Release.
The app's SUFeedURL points at the raw appcast.xml on GitHub — $0 hosting.
▸ Distribution touchpoints (all $0 marginal cost):
- Model weights: Hugging Face CDN
- Updates: GitHub Releases + Sparkle
- Licensing: Lemon Squeezy License API (% per sale only)
NOTE
echo "✓ Release steps printed for $VERSION"

33
scripts/sign-notarize.sh Executable file
View File

@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Codesign (Hardened Runtime) + notarize + staple the GrammaGem.app bundle.
# Requires the Apple Developer Program ($99/yr) — a FIXED cost, not per-user.
#
# Prereqs (set as env vars or fill in):
# DEV_ID e.g. "Developer ID Application: Your Name (TEAMID)"
# KEYCHAIN_PROFILE a stored notarytool profile (xcrun notarytool store-credentials)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
APP="$ROOT/dist/GrammaGem.app"
ENTITLEMENTS="$ROOT/AppSupport/GrammaGem.entitlements"
: "${DEV_ID:?Set DEV_ID to your Developer ID Application identity}"
: "${KEYCHAIN_PROFILE:?Set KEYCHAIN_PROFILE to your stored notarytool profile}"
echo "▸ Codesigning with Hardened Runtime…"
codesign --force --deep --options runtime \
--entitlements "$ENTITLEMENTS" \
--sign "$DEV_ID" \
"$APP"
echo "▸ Zipping for notarization…"
ZIP="$ROOT/dist/GrammaGem.zip"
ditto -c -k --keepParent "$APP" "$ZIP"
echo "▸ Submitting to Apple notary service…"
xcrun notarytool submit "$ZIP" --keychain-profile "$KEYCHAIN_PROFILE" --wait
echo "▸ Stapling ticket…"
xcrun stapler staple "$APP"
echo "✓ Signed, notarized, and stapled: $APP"