Merge pull request #1 from BigMarc/harper-engine-and-ax-fix

Harper engine and ax fix
This commit is contained in:
BigMarc 2026-06-20 22:25:07 +02:00 committed by GitHub
commit 5af4e65bfe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
86 changed files with 8866 additions and 853 deletions

8
.gitignore vendored
View File

@ -3,3 +3,11 @@ dist/
*.xcodeproj/xcuserdata/ *.xcodeproj/xcuserdata/
.DS_Store .DS_Store
.swiftpm/ .swiftpm/
# Rust / Harper FFI build artifacts (regenerate with harper-ffi/build.sh).
# Cargo.lock IS committed (binary crate) for reproducible builds.
harper-ffi/target/
harper-ffi/lib/
# xcodebuild DerivedData (used only to generate the MLX metallib)
.xcode-build/

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- <!--
Info.plist for the packaged GrammaGem.app bundle. Info.plist for the packaged GrammarGem.app bundle.
`swift build` produces a bare executable; to ship a real menu-bar app you wrap `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 it in a .app bundle with this Info.plist (see scripts/build.sh) or, more
@ -18,13 +18,13 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>GrammaGem</string> <string>GrammarGem</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>GrammaGem</string> <string>GrammarGem</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>com.grammagem.app</string> <string>com.grammargem.app</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>GrammaGem</string> <string>GrammarGem</string>
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
<string>AppIcon</string> <string>AppIcon</string>
<key>CFBundleIconName</key> <key>CFBundleIconName</key>
@ -43,5 +43,19 @@
<string>© 2026 FounderGem LLC. Built in public.</string> <string>© 2026 FounderGem LLC. Built in public.</string>
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string>NSApplication</string> <string>NSApplication</string>
<!-- Sparkle auto-update. Appcast is served from grammargem.com and updates are
EdDSA-signed with the key whose public half is below (private key lives in
the release machine's keychain; sign with scripts/release.sh). -->
<key>SUFeedURL</key>
<string>https://www.grammargem.com/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>IwYch+DRgNk3gmCwe86z+SIc4sRdyDf4Z6xGpS+JXDc=</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUScheduledCheckInterval</key>
<integer>86400</integer>
<key>SUAutomaticallyUpdate</key>
<false/>
</dict> </dict>
</plist> </plist>

BIN
AppSupport/mlx.metallib Normal file

Binary file not shown.

78
Package.resolved Normal file
View File

@ -0,0 +1,78 @@
{
"originHash" : "f78005d8f2f9a6a31d5c42cc7ce2b46907eb92fce74810060a58056e592ea3a1",
"pins" : [
{
"identity" : "gzipswift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/1024jp/GzipSwift",
"state" : {
"revision" : "731037f6cc2be2ec01562f6597c1d0aa3fe6fd05",
"version" : "6.0.1"
}
},
{
"identity" : "mlx-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ml-explore/mlx-swift",
"state" : {
"revision" : "96c0f69cf15f38abd6248bb71fca908171dab1d8",
"version" : "0.25.6"
}
},
{
"identity" : "mlx-swift-examples",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ml-explore/mlx-swift-examples.git",
"state" : {
"revision" : "8775112da257e8b0a5bf45e3f84fb80222592c89",
"version" : "2.25.9"
}
},
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "d46d456107feacc80711b21847b82b07bd9fb46e",
"version" : "2.9.3"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a",
"version" : "1.6.0"
}
},
{
"identity" : "swift-jinja",
"kind" : "remoteSourceControl",
"location" : "https://github.com/huggingface/swift-jinja.git",
"state" : {
"revision" : "0b67ecb79139f6addef8699eff3622808aa6c7dc",
"version" : "2.3.6"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-numerics",
"state" : {
"revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
"version" : "1.1.1"
}
},
{
"identity" : "swift-transformers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/huggingface/swift-transformers",
"state" : {
"revision" : "a2e184dddb4757bc943e77fbe99ac6786c53f0b2",
"version" : "1.0.0"
}
}
],
"version" : 3
}

View File

@ -1,37 +1,58 @@
// swift-tools-version:5.10 // swift-tools-version:5.10
import PackageDescription import PackageDescription
import Foundation
// GrammaGem — native macOS menu-bar writing assistant. // GrammarGem — native macOS menu-bar writing assistant.
// //
// This package builds with ONLY Apple frameworks so it compiles cleanly out of // Layer-1 grammar is the real **Harper** core (Apache-2.0), embedded as a Rust
// the box (`swift build`). The heavyweight third-party pieces from the spec — // C-FFI static library (see `harper-ffi/`). Build the lib first with
// Harper (Rust grammar core), MLX (local LLM), KeyboardShortcuts, Sparkle — are // `harper-ffi/build.sh`; `scripts/build.sh` does this automatically.
// abstracted behind protocols with working stub implementations and clearly //
// marked `TODO(real-integration)` seams. See README.md for wiring the real deps. // Layer-2 AI (rewrite / tone / Ask) is the real on-device LLM via MLX
// (mlx-swift-examples). MLX is **Apple-Silicon / Metal only**, so the app is
// arm64-only (matches the product spec: Apple Silicon, macOS 14+). The Harper
// static lib stays universal; the linker just uses its arm64 slice.
// Absolute path to the prebuilt Harper static lib, independent of build cwd.
let packageRoot = URL(fileURLWithPath: #filePath).deletingLastPathComponent().path
let harperLibDir = packageRoot + "/harper-ffi/lib"
let package = Package( let package = Package(
name: "GrammaGem", name: "GrammarGem",
platforms: [ platforms: [
.macOS(.v14) // Apple Silicon, macOS 14+ (per the product spec) .macOS(.v14) // Apple Silicon, macOS 14+ (per the product spec)
], ],
products: [ products: [
.executable(name: "GrammaGem", targets: ["GrammaGem"]) .executable(name: "GrammarGem", targets: ["GrammarGem"])
], ],
dependencies: [ dependencies: [
// TODO(real-integration): add when wiring real engines / distribution. // On-device LLM runtime. Pinned to an exact tag (the LLM libraries still
// .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.0.0"), // ship from this repo at 2.25.9; the package name is "mlx-libraries").
// .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"), .package(url: "https://github.com/ml-explore/mlx-swift-examples.git", exact: "2.25.9"),
// .package(url: "https://github.com/ml-explore/mlx-swift", from: "0.18.0"), // Secure auto-updates (EdDSA-signed appcast served from grammargem.com).
// The Harper grammar core is bundled via a Rust static lib + C-FFI; see harper-ffi/. .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"),
], ],
targets: [ targets: [
// C shim exposing libharper_ffi's C ABI (harper-ffi/include/harper.h) to Swift.
.target(name: "CHarper", path: "Sources/CHarper"),
.executableTarget( .executableTarget(
name: "GrammaGem", name: "GrammarGem",
path: "Sources/GrammaGem" dependencies: [
"CHarper",
.product(name: "MLXLLM", package: "mlx-swift-examples"),
.product(name: "MLXLMCommon", package: "mlx-swift-examples"),
.product(name: "Sparkle", package: "Sparkle"),
],
path: "Sources/GrammarGem",
linkerSettings: [
// Link the prebuilt Harper static library (arm64 slice).
.unsafeFlags(["-L\(harperLibDir)", "-lharper_ffi"])
]
), ),
.testTarget( .testTarget(
name: "GrammaGemTests", name: "GrammarGemTests",
dependencies: ["GrammaGem"], dependencies: ["GrammarGem"],
path: "Tests/GrammaGemTests" path: "Tests/GrammarGemTests"
), ),
] ]
) )

View File

@ -1,6 +1,6 @@
<div align="center"> <div align="center">
# ✒️ GrammaGem # ✒️ GrammarGem
### Write it once. Get it right. ### Write it once. Get it right.
@ -12,7 +12,7 @@ Fix grammar, sharpen tone, and rewrite anything — in every app — for one pay
![One-time purchase](https://img.shields.io/badge/pricing-one--time-C9A24B) ![One-time purchase](https://img.shields.io/badge/pricing-one--time-C9A24B)
![License: MIT](https://img.shields.io/badge/license-MIT-0E7C5A) ![License: MIT](https://img.shields.io/badge/license-MIT-0E7C5A)
[**⬇️ Download GrammaGem**](https://grammagem.app/#pricing) · [Docs](https://grammagem.app/docs) · [How it works](https://grammagem.app) [**⬇️ Download GrammarGem**](https://www.grammargem.com/#pricing) · [Docs](https://www.grammargem.com/docs) · [How it works](https://www.grammargem.com)
</div> </div>
@ -20,7 +20,7 @@ Fix grammar, sharpen tone, and rewrite anything — in every app — for one pay
## Stop renting your grammar checker. ## Stop renting your grammar checker.
Grammarly costs around **$144 every year**, reads everything you type, and sends it to their servers. GrammaGem does the same job — **better** — entirely on your Mac, for a **single payment**. Grammarly costs around **$144 every year**, reads everything you type, and sends it to their servers. GrammarGem does the same job — **better** — entirely on your Mac, for a **single payment**.
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**. 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**.
@ -30,7 +30,7 @@ Select text in any app, press one hotkey, and it’s polished in place. No copy-
## Why people switch ## Why people switch
| | Grammarly | **GrammaGem** | | | Grammarly | **GrammarGem** |
|---|:---:|:---:| |---|:---:|:---:|
| **Price** | ~$144 / year, forever | **Pay once** | | **Price** | ~$144 / year, forever | **Pay once** |
| **Your words** | Sent to the cloud | **Never leave your Mac** | | **Your words** | Sent to the cloud | **Never leave your Mac** |
@ -46,7 +46,7 @@ Select text in any app, press one hotkey, and it’s polished in place. No copy-
- 🔒 **Private by design** — everything runs on your Mac’s Neural Engine. Your writing is never uploaded, logged, or used to train a model. - 🔒 **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. - ⌨️ **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. - ✨ **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. - 🎚️ **Writing Modes** — Email, Post, Team Chat, Academic, Code — switch the tone instantly, or let GrammarGem 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. - 📖 **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. - 🖥️ **A clean management app** — manage your devices, shortcuts, modes, and privacy from one tidy window.
@ -67,7 +67,7 @@ No subscription. No per-seat fees. One payment, lifetime updates.
<div align="center"> <div align="center">
[**⬇️ Get GrammaGem**](https://grammagem.app/#pricing) [**⬇️ Get GrammarGem**](https://www.grammargem.com/#pricing)
</div> </div>
@ -85,17 +85,17 @@ No. Core features work fully offline.
No — it’s a one-time purchase with lifetime updates and a 14-day money-back guarantee. No — it’s a one-time purchase with lifetime updates and a 14-day money-back guarantee.
**What about plagiarism detection?** **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. GrammarGem 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 ## 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. GrammarGem 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"> <div align="center">
**[grammagem.app](https://grammagem.app)** **[grammargem.com](https://www.grammargem.com)**
© 2026 FounderGem LLC · 1309 Coffeen Avenue STE 1200, Sheridan, WY 82801, USA © 2026 FounderGem LLC · 1309 Coffeen Avenue STE 1200, Sheridan, WY 82801, USA

View File

@ -1,7 +1,7 @@
# Built-in Writing Modes # Built-in Writing Modes
Each Mode is a named system-prompt preset for the local LLM. The canonical, 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); compiled definitions live in [`Sources/GrammarGem/AI/ModeRegistry.swift`](../../Sources/GrammarGem/AI/ModeRegistry.swift);
these JSON files mirror them for transparency and easy editing. these JSON files mirror them for transparency and easy editing.
| Mode | Tier | In public repo? | | Mode | Tier | In public repo? |

View File

@ -0,0 +1,37 @@
/*
* harper.h — C ABI for the harper-ffi static library (harper-core 2.5.0).
*
* Symbols are provided by libharper_ffi.a (linked via -lharper_ffi). All
* strings are UTF-8. See harper-ffi/ for the Rust source.
*/
#ifndef HARPER_FFI_H
#define HARPER_FFI_H
#ifdef __cplusplus
extern "C" {
#endif
/*
* Check `text` for grammar/spelling issues using harper-core.
*
* Returns a malloc'd, NUL-terminated UTF-8 JSON C string the caller MUST free
* with harper_free(). The JSON is an array of objects:
* [{ "start": <char offset>, "len": <char count>,
* "replacement": "<string>", "message": "<string>", "kind": "<string>" }]
*
* - "start"/"len" are CHAR (Unicode scalar) offsets, NOT bytes/UTF-16.
* - "kind" is one of: spelling, grammar, punctuation, repetition,
* capitalization, phrasing.
*
* On null/invalid input or any internal error, returns "[]". Thread-safe.
*/
char *harper_check(const char *text);
/* Free a string previously returned by harper_check(). NULL is a no-op. */
void harper_free(char *ptr);
#ifdef __cplusplus
}
#endif
#endif /* HARPER_FFI_H */

View File

@ -0,0 +1,4 @@
module CHarper {
header "harper.h"
export *
}

4
Sources/CHarper/shim.c Normal file
View File

@ -0,0 +1,4 @@
// CHarper is a header-only shim that exposes libharper_ffi's C ABI (declared in
// include/harper.h) to Swift as the `CHarper` module. The actual symbols are
// provided by libharper_ffi.a, linked via the GrammarGem target's linkerSettings.
// This translation unit exists only so SwiftPM treats CHarper as a C target.

View File

@ -1,32 +0,0 @@
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

@ -1,91 +0,0 @@
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

@ -1,34 +0,0 @@
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 {
MenuBarContent()
.environmentObject(app)
} label: {
MenuBarLabel()
}
.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

@ -1,138 +0,0 @@
import SwiftUI
/// First-run onboarding: explains why GrammaGem needs Accessibility, deep-links
/// to System Settings, and advances automatically the moment permission is
/// granted (even if granted directly in System Settings).
struct OnboardingView: View {
@EnvironmentObject private var app: AppState
@ObservedObject private var permissions: Permissions
@ObservedObject private var model: ModelManager
init() {
_permissions = ObservedObject(wrappedValue: AppState.shared.permissions)
_model = ObservedObject(wrappedValue: AppState.shared.model)
}
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: permissions.accessibilityTrusted
) {
if permissions.accessibilityTrusted {
Label("Permission granted", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
} else {
HStack {
Button("Open System Settings") { permissions.openAccessibilitySettings() }
Button("Request permission") { permissions.requestAccessibility() }
}
Text("This updates automatically once you flip GrammaGem on in Accessibility.")
.font(.caption).foregroundStyle(.secondary)
}
}
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: model.state == .ready
) {
ModelDownloadControls()
}
Spacer()
HStack {
if permissions.accessibilityTrusted {
Label("You're all set", systemImage: "sparkles").foregroundStyle(.secondary)
}
Spacer()
Button(permissions.accessibilityTrusted ? "Done" : "Continue without permission") {
app.showOnboardingDismiss()
}
.keyboardShortcut(.defaultAction)
}
}
.padding(28)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear {
permissions.startPollingUntilGranted()
}
.onDisappear {
permissions.stopPolling()
}
}
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))
}
}
/// Real model download progress + controls, shared by onboarding and settings.
struct ModelDownloadControls: View {
@ObservedObject private var model: ModelManager
init() {
_model = ObservedObject(wrappedValue: AppState.shared.model)
}
var body: some View {
switch model.state {
case .notDownloaded:
VStack(alignment: .leading, spacing: 4) {
Button("Download model now") { model.startDownload() }
Text("Installs automatically in the background on first launch (about 1–2 GB from Hugging Face). One time, then fully offline.")
.font(.caption).foregroundStyle(.secondary)
}
case .downloading(let progress):
VStack(alignment: .leading, spacing: 6) {
ProgressView(value: progress) {
Text("Downloading… \(Int(progress * 100))%")
}
.frame(maxWidth: 320)
if !model.statusText.isEmpty {
Text(model.statusText).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Button("Cancel") { model.cancel() }.controlSize(.small)
}
case .ready:
Label("Model ready", systemImage: "checkmark.seal.fill").foregroundStyle(.green)
case .failed(let message):
VStack(alignment: .leading, spacing: 4) {
Label("Download failed", systemImage: "exclamationmark.triangle").foregroundStyle(.orange)
Text(message).font(.caption).foregroundStyle(.secondary).lineLimit(2)
Button("Retry") { model.startDownload() }
}
}
}
}

View File

@ -1,75 +0,0 @@
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

@ -1,93 +0,0 @@
import Foundation
import AppKit
/// Real, on-device grammar + spelling using macOS's built-in `NSSpellChecker`
/// (the same engine TextEdit/Mail use). No network, no model — fully local,
/// matching the privacy promise. Augmented with a few deterministic Harper-style
/// rules (repeated words, lowercase "i", common expansions) the system checker
/// doesn't surface.
final class SystemGrammarEngine: GrammarEngine {
var ignoreList: Set<String> = []
private let checker = NSSpellChecker.shared
private let tag = NSSpellChecker.uniqueSpellDocumentTag()
private let extras = HarperEngine()
func check(_ text: String) -> [Suggestion] {
guard text.count >= 2 else { return [] }
return onMain { self.checkOnMain(text) }
}
private func checkOnMain(_ text: String) -> [Suggestion] {
let ns = text as NSString
let full = NSRange(location: 0, length: ns.length)
let language = checker.language()
var out: [Suggestion] = []
let types = NSTextCheckingResult.CheckingType([.spelling, .grammar]).rawValue
let results = checker.check(
text, range: full, types: types, options: nil,
inSpellDocumentWithTag: tag, orthography: nil, wordCount: nil)
for r in results {
switch r.resultType {
case .spelling:
let word = ns.substring(with: r.range)
if ignoreList.contains(word.lowercased()) { continue }
let fix = checker.correction(
forWordRange: r.range, in: text, language: language, inSpellDocumentWithTag: tag)
?? checker.guesses(
forWordRange: r.range, in: text, language: language,
inSpellDocumentWithTag: tag)?.first
out.append(Suggestion(
location: r.range.location, length: r.range.length,
original: word, replacement: fix ?? word,
kind: .spelling, message: "Possible spelling mistake"))
case .grammar:
let details = r.grammarDetails ?? []
if details.isEmpty {
let phrase = ns.substring(with: r.range)
out.append(Suggestion(
location: r.range.location, length: r.range.length,
original: phrase, replacement: phrase,
kind: .grammar, message: "Grammar issue"))
} else {
for d in details {
var loc = r.range.location, len = r.range.length
if let rv = d[NSGrammarRange] as? NSValue {
let sub = rv.rangeValue
loc = r.range.location + sub.location
len = sub.length
}
guard len > 0, loc + len <= ns.length else { continue }
let phrase = ns.substring(with: NSRange(location: loc, length: len))
let message = (d[NSGrammarUserDescription] as? String) ?? "Grammar issue"
let corrections = (d[NSGrammarCorrections] as? [String]) ?? []
out.append(Suggestion(
location: loc, length: len,
original: phrase, replacement: corrections.first ?? phrase,
kind: .grammar, message: message))
}
}
default:
break
}
}
// Deterministic extras, skipping anything overlapping a system result.
extras.ignoreList = ignoreList
for h in extras.check(text) where !out.contains(where: { overlaps($0.range, h.range) }) {
out.append(h)
}
return out.sorted { $0.location < $1.location }
}
private func overlaps(_ a: NSRange, _ b: NSRange) -> Bool {
NSIntersectionRange(a, b).length > 0
}
private func onMain<T>(_ work: @escaping () -> T) -> T {
Thread.isMainThread ? work() : DispatchQueue.main.sync(execute: work)
}
}

View File

@ -1,90 +0,0 @@
import Foundation
/// Privacy-friendly, fully-local usage insights. Counts *how often* features are
/// used — never *what* was written. Stored only on this Mac.
@MainActor
final class UsageStats: ObservableObject {
struct Snapshot: Codable, Equatable {
var totalCorrections = 0
var totalAIActions = 0
var totalWords = 0
var perDay: [String: Int] = [:] // "yyyy-MM-dd" -> actions that day
}
@Published private(set) var data: Snapshot
private let key = "GrammaGem.usageStats"
private let d = UserDefaults.standard
private static let fmt: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = .current
return f
}()
init() {
if let raw = d.data(forKey: key),
let decoded = try? JSONDecoder().decode(Snapshot.self, from: raw) {
data = decoded
} else {
data = Snapshot()
}
}
func recordCorrection(words: Int) {
data.totalCorrections += 1
data.totalWords += max(0, words)
bumpToday()
persist()
}
func recordAIAction(words: Int) {
data.totalAIActions += 1
data.totalWords += max(0, words)
bumpToday()
persist()
}
/// A playful, clearly-estimated "time saved" figure (local only).
var estimatedMinutesSaved: Int {
(data.totalCorrections * 8 + data.totalAIActions * 45) / 60
}
/// Counts for the last 7 calendar days, oldest → newest, for a small chart.
func last7Days() -> [(label: String, count: Int)] {
let cal = Calendar.current
let today = Date()
return (0..<7).reversed().map { offset in
let day = cal.date(byAdding: .day, value: -offset, to: today) ?? today
let key = Self.fmt.string(from: day)
let short = day.formatted(.dateTime.weekday(.narrow))
return (label: short, count: data.perDay[key] ?? 0)
}
}
func reset() {
data = Snapshot()
persist()
}
private func bumpToday() {
let k = Self.fmt.string(from: Date())
data.perDay[k, default: 0] += 1
// Keep only ~60 days of history.
if data.perDay.count > 70 {
let keep = Set((0..<60).compactMap { off -> String? in
guard let day = Calendar.current.date(byAdding: .day, value: -off, to: Date()) else { return nil }
return Self.fmt.string(from: day)
})
data.perDay = data.perDay.filter { keep.contains($0.key) }
}
}
private func persist() {
if let raw = try? JSONEncoder().encode(data) {
d.set(raw, forKey: key)
}
}
}

View File

@ -1,39 +0,0 @@
import Foundation
import ApplicationServices
/// Safe Accessibility helpers shared across the System layer.
///
/// `AXUIElementCopyAttributeValue` returning `.success` only guarantees a
/// non-null `CFTypeRef` — NOT that it is an `AXUIElement`. A misbehaving AX
/// server (Electron/Java/custom) can return a different CF type, and a forced
/// `as!` cast then traps and crashes the whole app. These helpers cast safely.
enum AX {
/// Safely bridge a CFTypeRef to AXUIElement, returning nil on type mismatch.
static func element(_ ref: CFTypeRef?) -> AXUIElement? {
guard let ref, CFGetTypeID(ref) == AXUIElementGetTypeID() else { return nil }
return (ref as! AXUIElement) // safe: type id verified above
}
/// Copy an attribute as an `AXUIElement` (nil on failure/type mismatch).
static func copyElement(_ element: AXUIElement, _ attribute: String) -> AXUIElement? {
var ref: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success else {
return nil
}
return AX.element(ref)
}
/// Copy an attribute as a `String` (nil on failure/wrong type).
static func copyString(_ element: AXUIElement, _ attribute: String) -> String? {
var ref: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success else {
return nil
}
return ref as? String
}
/// Bound each cross-process AX message so a wedged target app can't hang us.
static func setMessagingTimeout(_ element: AXUIElement, _ seconds: Float) {
AXUIElementSetMessagingTimeout(element, seconds)
}
}

View File

@ -0,0 +1,73 @@
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.
/// `protectedTerms` are vocabulary the model must reproduce verbatim (the
/// personal dictionary / style-guide terms). Throws on model-not-ready or
/// generation failure. Never performs network I/O on the on-device backend.
func run(_ action: AIAction, on text: String, protectedTerms: [String]) async throws -> String
/// Best-effort local tone classification (used by tone-detection UI).
func detectTone(_ text: String) async -> Tone
/// Optionally preload the model so the first real action is instant.
func warmup() async
/// Streaming variant: yields incremental output as it generates. The default
/// implementation wraps `run` as a single chunk, so a backend that doesn't
/// natively stream still conforms.
func runStreaming(_ action: AIAction, on text: String, protectedTerms: [String])
-> AsyncThrowingStream<String, Error>
}
extension AIEngine {
/// Convenience: run with no protected terms.
func run(_ action: AIAction, on text: String) async throws -> String {
try await run(action, on: text, protectedTerms: [])
}
/// Default: no preload.
func warmup() async {}
/// Default streaming: produce the full result, then emit it as one chunk.
func runStreaming(_ action: AIAction, on text: String, protectedTerms: [String])
-> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
Task {
do {
continuation.yield(try await run(action, on: text, protectedTerms: protectedTerms))
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
/// Convenience streaming overload with no protected terms.
func runStreaming(_ action: AIAction, on text: String) -> AsyncThrowingStream<String, Error> {
runStreaming(action, on: text, protectedTerms: [])
}
}
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,111 @@
import Foundation
/// Backend-neutral prompt construction, generation parameters, and output
/// cleanup for Layer-2 actions. Extracted from `MLXEngine` so EVERY `AIEngine`
/// backend (MLX today, Ollama/llama.cpp next, the localhost extension server) is
/// guaranteed to produce identical prompts and identical output trimming — no
/// behavioral drift between runtimes — and so the personal dictionary / a future
/// style guide can inject protected vocabulary in exactly ONE place.
///
/// Everything here is a pure function of its inputs (no MLX, no I/O), which also
/// makes per-action behavior unit-testable without a model present.
enum AIPrompts {
/// The system prompt for an action. `protectedTerms` (personal-dictionary
/// entries + future style-guide vocabulary) are terms the model must never
/// alter, so AI rewrites stop "correcting" brand names and jargon.
static func systemPrompt(for action: AIAction, protectedTerms: [String] = []) -> String {
base(for: action) + protectedTermsClause(protectedTerms)
}
private static func base(for action: AIAction) -> String {
switch action {
case .rewriteClarity:
return "Rewrite the user's text to be clearer and more concise. Preserve the "
+ "original meaning and the author's voice. Output only the rewritten text."
case .rewrite:
return "Paraphrase the user's text. Keep the meaning but vary the wording and "
+ "structure. Output only the rewritten text."
case .adjustTone(let tone):
return "Rewrite the user's text in a \(tone.rawValue) tone. Preserve the meaning. "
+ "Output only the rewritten text."
case .ask(let instruction):
return "Apply this instruction to the user's text: \(instruction). "
+ "Output only the resulting text."
case .translate(let language):
return "Translate the user's text into \(language). Output only the translation."
case .applyMode(let mode):
return mode.systemPrompt
}
}
/// Appended to every system prompt: terms the model must reproduce verbatim
/// (brand names, jargon the user added to their personal dictionary). Capped
/// so a large dictionary can't blow the context window.
private static func protectedTermsClause(_ terms: [String]) -> String {
let cleaned = terms
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard !cleaned.isEmpty else { return "" }
let list = cleaned.prefix(50).map { "\"\($0)\"" }.joined(separator: ", ")
return " Never change, translate, or \"correct\" these exact terms — reproduce them "
+ "verbatim, including capitalization: \(list)."
}
/// Low temperature for faithful rewrites; a touch higher for free paraphrase.
static func temperature(for action: AIAction) -> Float {
switch action {
case .rewrite: return 0.5
case .ask: return 0.4
default: return 0.2
}
}
/// Generation token budget. Honors a Mode's `lengthCap` (in characters) for
/// short-form modes (Post = 280, Team Chat = 60) so we don't spend the full
/// 512-token default on a one-line message; falls back to the default otherwise.
static func maxTokens(for action: AIAction) -> Int {
let fallback = 512
guard case .applyMode(let mode) = action, let cap = mode.lengthCap else { return fallback }
// ~1 token per 3 characters of English, plus headroom; clamp to a floor.
return min(fallback, max(64, cap / 3 + 48))
}
/// Strip a leading "Sure, here's …:" preamble and surrounding quotes that small
/// instruct models sometimes add, so the result drops cleanly into the field.
static func clean(_ s: String) -> String {
var out = s.trimmingCharacters(in: .whitespacesAndNewlines)
let lower = out.lowercased()
for prefix in ["sure, here", "sure! here", "here is", "here's", "certainly"] {
if lower.hasPrefix(prefix), let colon = out.firstIndex(of: ":") {
let after = out[out.index(after: colon)...]
.trimmingCharacters(in: .whitespacesAndNewlines)
if !after.isEmpty { out = after }
break
}
}
if out.count >= 2, out.first == "\"", out.last == "\"" {
out = String(out.dropFirst().dropLast())
}
return out.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// The tone-classifier system prompt (kept here so any backend reuses it).
static func toneClassifierPrompt() -> String {
let options = Tone.allCases.map(\.rawValue).joined(separator: ", ")
return """
You are a tone classifier. Read the user's text and respond with exactly ONE \
word — the single best-matching tone from this list: \(options). \
Output only that word, lowercase, no punctuation or explanation.
"""
}
/// Parse the one-word tone classification output into a `Tone`.
static func parseTone(_ raw: String) -> Tone {
let token = raw.lowercased().trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
if let exact = Tone(rawValue: token) { return exact }
let lower = raw.lowercased()
for tone in Tone.allCases where lower.contains(tone.rawValue) { return tone }
return .professional
}
}

View File

@ -8,8 +8,8 @@ final class CustomModesStore: ObservableObject {
/// bundleID -> mode id (overrides the default App-Aware mapping). /// bundleID -> mode id (overrides the default App-Aware mapping).
@Published private(set) var appOverrides: [String: String] @Published private(set) var appOverrides: [String: String]
private let modesKey = "GrammaGem.customModes" private let modesKey = "GrammarGem.customModes"
private let mapKey = "GrammaGem.appModeOverrides" private let mapKey = "GrammarGem.appModeOverrides"
private let d = UserDefaults.standard private let d = UserDefaults.standard
init() { init() {
@ -25,19 +25,27 @@ final class CustomModesStore: ObservableObject {
/// Built-in + custom modes (custom ones are always paid). /// Built-in + custom modes (custom ones are always paid).
func allModes() -> [WritingMode] { ModeRegistry.all + customModes } func allModes() -> [WritingMode] { ModeRegistry.all + customModes }
func addCustom(name: String, prompt: String) { /// Returns false (no-op) if the name or prompt is empty, or the name
/// collides with an existing built-in/custom mode — so an empty-prompt mode
/// can't slip through to the LLM as an empty system prompt.
@discardableResult
func addCustom(name: String, prompt: String) -> Bool {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return } let trimmedPrompt = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !trimmedPrompt.isEmpty else { return false }
guard !allModes().contains(where: { $0.name.caseInsensitiveCompare(trimmed) == .orderedSame })
else { return false }
let mode = WritingMode( let mode = WritingMode(
id: "custom-\(UUID().uuidString.prefix(8))", id: "custom-\(UUID().uuidString.prefix(8))",
name: trimmed, name: trimmed,
systemPrompt: prompt.trimmingCharacters(in: .whitespacesAndNewlines), systemPrompt: trimmedPrompt,
isPaid: true, isPaid: true,
lengthCap: nil, lengthCap: nil,
autoFormat: "Custom" autoFormat: "Custom"
) )
customModes.append(mode) customModes.append(mode)
persistModes() persistModes()
return true
} }
func removeCustom(id: String) { func removeCustom(id: String) {

View File

@ -0,0 +1,149 @@
import Foundation
import MLXLMCommon
import MLXLLM
/// Layer 2 — the real on-device LLM (rewrite / tone / "Ask" / translate), backed
/// by **MLX** via mlx-swift-examples (verified against tag 2.25.9; SPM package
/// "mlx-libraries", products MLXLLM + MLXLMCommon).
///
/// Loads weights ONLY from a local on-disk snapshot (ModelManager's download
/// directory) — `ModelConfiguration(directory:)` never contacts the network, so
/// inference is fully on-device, honoring the privacy promise.
///
/// API surface used (all confirmed in the 2.25.9 source):
/// ModelConfiguration(directory:) MLXLMCommon/ModelConfiguration.swift
/// LLMModelFactory.shared.loadContainer(...) MLXLLM/LLMModelFactory.swift
/// ModelContainer.perform { context in ... } MLXLMCommon/ModelContainer.swift
/// context.processor.prepare(input: UserInput(chat:)) MLXLMCommon/UserInput.swift
/// MLXLMCommon.generate(...) -> AsyncStream<Generation> MLXLMCommon/Evaluate.swift
final class MLXEngine: AIEngine {
/// Returns the local model snapshot directory if a complete model is present,
/// else nil. Owned by ModelManager; MLXEngine only loads + infers.
private let modelDirectoryProvider: () -> URL?
/// Serializes the (expensive) first load and caches the container.
private let loader = ContainerLoader()
init(modelDirectoryProvider: @escaping () -> URL?) {
self.modelDirectoryProvider = modelDirectoryProvider
}
var isReady: Bool { modelDirectoryProvider() != nil }
// MARK: - AIEngine
func run(_ action: AIAction, on text: String, protectedTerms: [String]) async throws -> String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return text }
let container = try await container()
let params = GenerateParameters(temperature: AIPrompts.temperature(for: action))
let raw = try await complete(
system: AIPrompts.systemPrompt(for: action, protectedTerms: protectedTerms),
user: trimmed,
maxTokens: AIPrompts.maxTokens(for: action),
parameters: params, container: container)
return AIPrompts.clean(raw)
}
/// Preload the model container so the first real action doesn't pay the
/// multi-second load. Safe to call repeatedly (the loader caches).
func warmup() async {
_ = try? await container()
}
func runStreaming(_ action: AIAction, on text: String, protectedTerms: [String])
-> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
Task {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continuation.yield(text); continuation.finish(); return }
do {
let container = try await container()
var params = GenerateParameters(temperature: AIPrompts.temperature(for: action))
params.maxTokens = AIPrompts.maxTokens(for: action)
let system = AIPrompts.systemPrompt(for: action, protectedTerms: protectedTerms)
try await container.perform { context in
let input = try await context.processor.prepare(
input: UserInput(chat: [.system(system), .user(trimmed)]))
let stream = try MLXLMCommon.generate(
input: input, parameters: params, context: context)
for await item in stream {
if let chunk = item.chunk { continuation.yield(chunk) }
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
func detectTone(_ text: String) async -> Tone {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .professional }
do {
let container = try await container()
let raw = try await complete(
system: AIPrompts.toneClassifierPrompt(), user: trimmed, maxTokens: 8,
parameters: GenerateParameters(temperature: 0.0), container: container)
return AIPrompts.parseTone(raw)
} catch {
return .professional
}
}
// MARK: - Loading
private func container() async throws -> ModelContainer {
guard let dir = modelDirectoryProvider() else { throw AIEngineError.modelNotReady }
return try await loader.container(at: dir)
}
// MARK: - Generation
private func complete(system: String, user: String, maxTokens: Int,
parameters: GenerateParameters,
container: ModelContainer) async throws -> String {
do {
return try await container.perform { context in
let input = try await context.processor.prepare(
input: UserInput(chat: [.system(system), .user(user)]))
var params = parameters
params.maxTokens = maxTokens
var out = ""
let stream = try MLXLMCommon.generate(
input: input, parameters: params, context: context)
for await item in stream {
if let chunk = item.chunk { out += chunk }
}
return out
}
} catch let error as AIEngineError {
throw error
} catch {
throw AIEngineError.generationFailed(error.localizedDescription)
}
}
// Prompt construction, temperature, token budget, and output cleanup now live
// in `AIPrompts` (backend-neutral; see AIPromptKit.swift) so every AIEngine
// backend produces identical prompts and identical output trimming.
}
/// Caches one loaded `ModelContainer` (keyed by directory) and coalesces
/// concurrent first-loads. `ModelContainer` is itself an actor, so generation
/// calls through it are serialized safely.
private actor ContainerLoader {
private var cached: (url: URL, container: ModelContainer)?
func container(at directory: URL) async throws -> ModelContainer {
if let cached, cached.url == directory { return cached.container }
let configuration = ModelConfiguration(directory: directory)
let container = try await LLMModelFactory.shared.loadContainer(configuration: configuration)
cached = (directory, container)
return container
}
}

View File

@ -14,7 +14,9 @@ final class ModelManager: ObservableObject {
@Published private(set) var state: State = .notDownloaded @Published private(set) var state: State = .notDownloaded
@Published private(set) var statusText: String = "" @Published private(set) var statusText: String = ""
@Published var selectedRepo: String = AppConfig.Model.defaultRepo @Published var selectedRepo: String = ModelManager.activeRepo() {
didSet { UserDefaults.standard.set(selectedRepo, forKey: Self.selectedRepoKey) }
}
private var task: Task<Void, Never>? private var task: Task<Void, Never>?
private var generation = 0 private var generation = 0
@ -23,20 +25,65 @@ final class ModelManager: ObservableObject {
if isModelPresent(repo: selectedRepo) { state = .ready } if isModelPresent(repo: selectedRepo) { state = .ready }
} }
var modelsDirectory: URL { var modelsDirectory: URL { Self.modelsDirectory() }
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
return base.appendingPathComponent("GrammaGem/Models", isDirectory: true)
}
func modelDir(_ repo: String) -> URL { func modelDir(_ repo: String) -> URL { Self.modelDir(repo) }
modelsDirectory.appendingPathComponent(repo.replacingOccurrences(of: "/", with: "_"), isDirectory: true)
}
/// Considered present once the completion marker is written. /// Considered present once the completion marker is written.
func isModelPresent(repo: String) -> Bool { func isModelPresent(repo: String) -> Bool { Self.isModelPresent(repo: repo) }
// MARK: - Nonisolated path helpers
//
// The AI engine loads weights off the main actor, so the directory lookup must
// be callable from any thread. These are pure FileManager/URL ops (no actor
// state, no network) and back the @MainActor instance accessors above.
nonisolated static func modelsDirectory() -> URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
return base.appendingPathComponent("GrammarGem/Models", isDirectory: true)
}
nonisolated static func modelDir(_ repo: String) -> URL {
modelsDirectory().appendingPathComponent(repo.replacingOccurrences(of: "/", with: "_"), isDirectory: true)
}
nonisolated static func isModelPresent(repo: String) -> Bool {
FileManager.default.fileExists(atPath: modelDir(repo).appendingPathComponent(".complete").path) FileManager.default.fileExists(atPath: modelDir(repo).appendingPathComponent(".complete").path)
} }
/// The local model directory iff a *complete* model is on disk, else nil —
/// fed to the AI engine as its network-free model source.
nonisolated static func completedModelDirectory(repo: String) -> URL? {
isModelPresent(repo: repo) ? modelDir(repo) : nil
}
/// UserDefaults key backing the user's chosen model repo.
nonisolated static let selectedRepoKey = "GrammarGem.selectedModelRepo"
/// The user's currently-selected repo, readable from ANY thread (it lives in
/// UserDefaults, which is thread-safe). The AI engine's model-directory
/// provider reads this so download, load, and `isReady` all agree on the SAME
/// repo — including the paid large model, which the old hardcoded-default
/// provider silently ignored. Falls back to the default repo when unset.
nonisolated static func activeRepo() -> String {
UserDefaults.standard.string(forKey: selectedRepoKey) ?? AppConfig.Model.defaultRepo
}
/// Safe URL construction for the two Hugging Face endpoints. A malformed
/// repo/path yields nil → a thrown `ModelError`, never a force-unwrap crash.
nonisolated static func fileURL(repo: String, path: String) -> URL? {
var c = URLComponents(string: AppConfig.Model.huggingFaceBase)
c?.path = "/\(repo)/resolve/main/\(path)"
return c?.url
}
nonisolated static func treeURL(repo: String) -> URL? {
var c = URLComponents(string: AppConfig.Model.huggingFaceBase)
c?.path = "/api/models/\(repo)/tree/main"
c?.queryItems = [URLQueryItem(name: "recursive", value: "1")]
return c?.url
}
/// Kick off (or resume to) a real download. Safe to call from the UI. /// Kick off (or resume to) a real download. Safe to call from the UI.
func startDownload(repo: String? = nil) { func startDownload(repo: String? = nil) {
let target = repo ?? selectedRepo let target = repo ?? selectedRepo
@ -74,7 +121,9 @@ final class ModelManager: ObservableObject {
try Task.checkCancellation() try Task.checkCancellation()
set(gen, .downloading(progress: min(1, Double(done) / Double(total))), set(gen, .downloading(progress: min(1, Double(done) / Double(total))),
"Downloading \(file.path) (\(byteString(file.size)))…") "Downloading \(file.path) (\(byteString(file.size)))…")
let url = URL(string: "\(AppConfig.Model.huggingFaceBase)/\(repo)/resolve/main/\(file.path)")! guard let url = Self.fileURL(repo: repo, path: file.path) else {
throw ModelError.invalidURL(file.path)
}
let (tmp, response) = try await URLSession.shared.download(from: url) let (tmp, response) = try await URLSession.shared.download(from: url)
if let http = response as? HTTPURLResponse, http.statusCode != 200 { if let http = response as? HTTPURLResponse, http.statusCode != 200 {
throw ModelError.http(http.statusCode, file.path) throw ModelError.http(http.statusCode, file.path)
@ -130,7 +179,7 @@ final class ModelManager: ObservableObject {
/// List the repo's files (+ sizes) from the Hugging Face tree API. /// List the repo's files (+ sizes) from the Hugging Face tree API.
private func fetchFileList(repo: String) async throws -> [(path: String, size: Int64)] { private func fetchFileList(repo: String) async throws -> [(path: String, size: Int64)] {
let url = URL(string: "\(AppConfig.Model.huggingFaceBase)/api/models/\(repo)/tree/main?recursive=1")! guard let url = Self.treeURL(repo: repo) else { throw ModelError.invalidURL("file list") }
let (data, response) = try await URLSession.shared.data(from: url) let (data, response) = try await URLSession.shared.data(from: url)
if let http = response as? HTTPURLResponse, http.statusCode != 200 { if let http = response as? HTTPURLResponse, http.statusCode != 200 {
throw ModelError.http(http.statusCode, "file list") throw ModelError.http(http.statusCode, "file list")
@ -148,11 +197,13 @@ final class ModelManager: ObservableObject {
case empty case empty
case http(Int, String) case http(Int, String)
case corrupt(String) case corrupt(String)
case invalidURL(String)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .empty: return "No model files were found for this repository." case .empty: return "No model files were found for this repository."
case .http(let code, let what): return "Download failed (\(code)) while fetching \(what)." case .http(let code, let what): return "Download failed (\(code)) while fetching \(what)."
case .corrupt(let what): return "Downloaded file looked wrong (\(what)); please retry." case .corrupt(let what): return "Downloaded file looked wrong (\(what)); please retry."
case .invalidURL(let what): return "Couldn't build a valid download URL for \(what)."
} }
} }
} }

View File

@ -0,0 +1,176 @@
import Foundation
/// A cross-platform Layer-2 backend that talks to a locally-running **Ollama**
/// server over HTTP. Conforms to the same `AIEngine` seam as `MLXEngine`, reuses
/// the backend-neutral `AIPrompts` (so prompts/output are identical across
/// runtimes), and depends only on `URLSession` — no MLX, no Metal — so it compiles
/// and runs anywhere, including Windows/Linux. This is what converts the AIEngine
/// abstraction from nominal to real and opens the non-Apple-Silicon market.
///
/// Privacy is preserved: Ollama runs the model on the user's own machine on
/// loopback (127.0.0.1); no text leaves the device.
final class OllamaEngine: AIEngine {
private let base: URL
private let model: String
private let session: URLSession
/// `isReady` is read synchronously by the coordinator but readiness for an HTTP
/// backend is an async probe, so we cache the last-known result behind a lock.
private let lock = NSLock()
private var cachedReady = false
init(base: URL = AppConfig.Ollama.baseURL,
model: String = AppConfig.Ollama.defaultModel,
session: URLSession = .shared) {
self.base = base
self.model = model
self.session = session
}
var isReady: Bool {
lock.lock(); defer { lock.unlock() }
return cachedReady
}
private func setReady(_ value: Bool) {
lock.lock(); cachedReady = value; lock.unlock()
}
// MARK: - AIEngine
/// Probe the server and confirm the configured model is pulled. Readiness for
/// Ollama means "server up + model present" (vs. MLX's "weights on disk").
func warmup() async {
setReady(await probeModelPresent())
}
func run(_ action: AIAction, on text: String, protectedTerms: [String]) async throws -> String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return text }
let raw = try await chat(
system: AIPrompts.systemPrompt(for: action, protectedTerms: protectedTerms),
user: trimmed,
temperature: AIPrompts.temperature(for: action),
maxTokens: AIPrompts.maxTokens(for: action))
setReady(true)
return AIPrompts.clean(raw)
}
func detectTone(_ text: String) async -> Tone {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .professional }
do {
let raw = try await chat(
system: AIPrompts.toneClassifierPrompt(), user: trimmed,
temperature: 0.0, maxTokens: 8)
return AIPrompts.parseTone(raw)
} catch {
return .professional
}
}
func runStreaming(_ action: AIAction, on text: String, protectedTerms: [String])
-> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
Task {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continuation.yield(text); continuation.finish(); return }
do {
try await streamChat(
system: AIPrompts.systemPrompt(for: action, protectedTerms: protectedTerms),
user: trimmed,
temperature: AIPrompts.temperature(for: action),
maxTokens: AIPrompts.maxTokens(for: action),
onChunk: { continuation.yield($0) })
setReady(true)
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
// MARK: - HTTP
private func probeModelPresent() async -> Bool {
do {
let (data, response) = try await session.data(from: base.appendingPathComponent("api/tags"))
guard (response as? HTTPURLResponse)?.statusCode == 200 else { return false }
let names = (try JSONDecoder().decode(TagsResponse.self, from: data)).models.map(\.name)
// Match exact or family (e.g. configured "qwen2.5:3b" vs a pulled "qwen2.5:3b-instruct").
return names.contains(model)
|| names.contains { $0.hasPrefix(model) || model.hasPrefix($0) }
} catch {
return false
}
}
private func chat(system: String, user: String,
temperature: Float, maxTokens: Int) async throws -> String {
let request = try makeRequest(path: "api/chat", body: ChatRequest(
model: model,
messages: [.init(role: "system", content: system), .init(role: "user", content: user)],
stream: false,
options: .init(temperature: temperature, num_predict: maxTokens)))
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw AIEngineError.generationFailed("No HTTP response from Ollama at \(base.absoluteString)")
}
guard http.statusCode == 200 else {
throw AIEngineError.generationFailed("Ollama returned HTTP \(http.statusCode)")
}
return (try JSONDecoder().decode(ChatResponse.self, from: data)).message?.content ?? ""
}
private func streamChat(system: String, user: String,
temperature: Float, maxTokens: Int,
onChunk: (String) -> Void) async throws {
let request = try makeRequest(path: "api/chat", body: ChatRequest(
model: model,
messages: [.init(role: "system", content: system), .init(role: "user", content: user)],
stream: true,
options: .init(temperature: temperature, num_predict: maxTokens)))
let (bytes, response) = try await session.bytes(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw AIEngineError.generationFailed("Ollama streaming request failed")
}
// Ollama streams newline-delimited JSON frames, one per token-ish chunk.
for try await line in bytes.lines {
guard let data = line.data(using: .utf8),
let frame = try? JSONDecoder().decode(ChatResponse.self, from: data),
let chunk = frame.message?.content, !chunk.isEmpty
else { continue }
onChunk(chunk)
}
}
private func makeRequest<T: Encodable>(path: String, body: T) throws -> URLRequest {
var request = URLRequest(url: base.appendingPathComponent(path))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(body)
return request
}
// MARK: - Wire types
private struct ChatRequest: Encodable {
let model: String
let messages: [Message]
let stream: Bool
let options: Options
struct Message: Encodable { let role: String; let content: String }
struct Options: Encodable { let temperature: Float; let num_predict: Int }
}
private struct ChatResponse: Decodable {
let message: Message?
struct Message: Decodable { let content: String }
}
private struct TagsResponse: Decodable {
let models: [Model]
struct Model: Decodable { let name: String }
}
}

View File

@ -27,8 +27,11 @@ final class AppState: ObservableObject {
let snippets: SnippetStore let snippets: SnippetStore
let exclusions: Exclusions let exclusions: Exclusions
let liveMonitor: LiveMonitor let liveMonitor: LiveMonitor
let updater: UpdaterManager
private let capture: TextCapture private let capture: TextCapture
let coordinator: TextReplacementCoordinator let coordinator: TextReplacementCoordinator
/// Loopback bridge to the browser extension (opt-in; nil unless enabled).
private var localServer: LocalEngineServer?
// UI state // UI state
@Published var lastStatus: String = "Ready" @Published var lastStatus: String = "Ready"
@ -45,7 +48,11 @@ final class AppState: ObservableObject {
private init() { private init() {
let engine = SystemGrammarEngine() let engine = SystemGrammarEngine()
grammar = engine grammar = engine
ai = MLXEngine(ready: true) // The on-device LLM loads weights from ModelManager's local snapshot (if a
// complete download is present) — never the network. Resolved lazily on use.
// Reads the user's *selected* repo (default or paid large model) so the
// model that gets loaded matches the one that gets downloaded.
ai = AppState.makeAIEngine()
permissions = Permissions() permissions = Permissions()
model = ModelManager() model = ModelManager()
license = LicenseManager() license = LicenseManager()
@ -61,8 +68,30 @@ final class AppState: ObservableObject {
let excl = Exclusions() let excl = Exclusions()
exclusions = excl exclusions = excl
liveMonitor = LiveMonitor(grammar: engine, detector: detector, exclusions: excl, capture: capture) liveMonitor = LiveMonitor(grammar: engine, detector: detector, exclusions: excl, capture: capture)
updater = UpdaterManager()
let dict = dictionary
coordinator = TextReplacementCoordinator( coordinator = TextReplacementCoordinator(
capture: capture, grammar: engine, ai: ai, gate: gate, detector: detector) capture: capture, grammar: engine, ai: ai, gate: gate, detector: detector,
protectedTerms: { dict.entries })
}
// MARK: - Backend factory
/// The single place the Layer-2 backend is chosen. Defaults to the MLX
/// on-device engine (Apple Silicon); a user/CI override selects the
/// cross-platform `OllamaEngine`. Adding a backend is a one-line branch here
/// instead of edits scattered across construction sites. The MLX provider
/// reads the user's *selected* repo so the loaded model matches the downloaded
/// one (incl. the paid large model).
static func makeAIEngine() -> AIEngine {
let override = ProcessInfo.processInfo.environment["GRAMMARGEM_AI_BACKEND"]
?? UserDefaults.standard.string(forKey: "GrammarGem.aiBackend")
if override == "ollama" {
return OllamaEngine()
}
return MLXEngine(modelDirectoryProvider: {
ModelManager.completedModelDirectory(repo: ModelManager.activeRepo())
})
} }
/// Called once at launch (from the AppDelegate). /// Called once at launch (from the AppDelegate).
@ -95,10 +124,19 @@ final class AppState: ObservableObject {
// Auto-install the on-device AI model in the background on first use, so // Auto-install the on-device AI model in the background on first use, so
// the AI features are ready without the user hunting for a button. The // the AI features are ready without the user hunting for a button. The
// download is resumable/cancelable from the AI Model screen. // download is resumable/cancelable from the AI Model screen. If a model is
if !model.isModelPresent(repo: model.selectedRepo) { // already present, warm it up now so the first AI action is instant.
if model.isModelPresent(repo: model.selectedRepo) {
Task { [weak self] in await self?.ai.warmup() }
} else {
model.startDownload() model.startDownload()
} }
// Warm the model up as soon as a (first-run) download completes.
model.$state.dropFirst()
.sink { [weak self] state in
if case .ready = state { Task { [weak self] in await self?.ai.warmup() } }
}
.store(in: &cancellables)
permissions.refresh() permissions.refresh()
appAwareEnabled = gate.appAwareEnabled appAwareEnabled = gate.appAwareEnabled
@ -109,7 +147,77 @@ final class AppState: ObservableObject {
} }
Task { await license.validateIfNeeded() } Task { await license.validateIfNeeded() }
Log.app.info("GrammaGem started. Licensed: \(self.license.isLicensed, privacy: .public)") startLocalServerIfEnabled()
Log.app.info("GrammarGem started. Licensed: \(self.license.isLicensed, privacy: .public)")
}
// MARK: - Browser-extension bridge (opt-in)
/// Start the loopback engine server only when the user has opted in, so the app
/// ships no listening socket by default. The server reuses the SAME engines,
/// prompts, and entitlement gate as the menu-bar app.
private func startLocalServerIfEnabled() {
let enabled = ProcessInfo.processInfo.environment["GRAMMARGEM_LOCAL_SERVER"] == "1"
|| UserDefaults.standard.bool(forKey: AppConfig.LocalServer.enableKey)
guard enabled, localServer == nil else { return }
let server = LocalEngineServer(
port: AppConfig.LocalServer.port,
token: LocalEngineServer.loadOrCreateToken(),
grammarHandler: { [weak self] text in
await MainActor.run {
guard let self else { return (text, []) }
let corrected = self.grammar.correct(text)
let suggestions = self.grammar.check(text).map {
LocalEngineServer.WireSuggestion(
location: $0.location, length: $0.length,
original: $0.original, replacement: $0.replacement,
kind: $0.kind.rawValue, message: $0.message)
}
return (corrected, suggestions)
}
},
aiHandler: { [weak self] (req: LocalEngineServer.AIRequest) async -> LocalEngineServer.AIOutcome in
guard let self else { return .error("GrammarGem is shutting down.") }
return await self.handleServerAI(req)
},
aiStreamHandler: { [weak self] (req, onChunk) -> LocalEngineServer.AIOutcome in
guard let self else { return .error("GrammarGem is shutting down.") }
return await self.handleServerAIStream(req, onChunk: onChunk)
})
server.start()
localServer = server
}
/// Run an AI action requested by the extension — same gate + model as the app.
private func handleServerAI(_ req: LocalEngineServer.AIRequest) async -> LocalEngineServer.AIOutcome {
if case .denied(let reason) = gate.authorizeAI(req.action) { return .error(reason) }
guard ai.isReady else { return .error(AIEngineError.modelNotReady.localizedDescription) }
do {
let out = try await ai.run(req.action, on: req.text, protectedTerms: dictionary.entries)
gate.recordAIActionUsed()
return .ok(out)
} catch {
return .error(error.localizedDescription)
}
}
/// Streaming variant for the extension: yields tokens as they generate.
private func handleServerAIStream(_ req: LocalEngineServer.AIRequest,
onChunk: @escaping (String) -> Void) async -> LocalEngineServer.AIOutcome {
if case .denied(let reason) = gate.authorizeAI(req.action) { return .error(reason) }
guard ai.isReady else { return .error(AIEngineError.modelNotReady.localizedDescription) }
var accumulated = ""
do {
for try await chunk in ai.runStreaming(req.action, on: req.text, protectedTerms: dictionary.entries) {
accumulated += chunk
onChunk(chunk)
}
gate.recordAIActionUsed()
return .ok(accumulated)
} catch {
return .error(error.localizedDescription)
}
} }
// MARK: - Hotkey handling // MARK: - Hotkey handling
@ -179,19 +287,39 @@ final class AppState: ObservableObject {
usage.recordCorrection(words: 1) usage.recordCorrection(words: 1)
} }
/// Pause/resume all of GrammaGem (hotkeys + live monitoring). /// Hide a single suggestion without changing the user's text.
func dismissSuggestion(_ suggestion: Suggestion, in field: DetectedField) {
liveMonitor.dismiss(suggestion, in: field)
}
/// Add a flagged word to the personal dictionary so it's never corrected
/// again (gated by the free cap), then re-scan so it disappears now.
@discardableResult
func addWordToDictionary(_ word: String) -> FeatureGate.Decision {
let decision = addDictionaryEntry(word)
switch decision {
case .allowed:
lastStatus = "Added “\(word)” to your dictionary"
liveMonitor.refreshSoon()
case .denied(let reason):
lastStatus = reason
}
return decision
}
/// Pause/resume all of GrammarGem (hotkeys + live monitoring).
func togglePause() { func togglePause() {
isPaused.toggle() isPaused.toggle()
liveMonitor.setPaused(isPaused) liveMonitor.setPaused(isPaused)
lastStatus = isPaused ? "Paused" : "Ready" lastStatus = isPaused ? "Paused" : "Ready"
} }
/// A reason GrammaGem should stay out of the way right now, or nil. /// A reason GrammarGem should stay out of the way right now, or nil.
private func blockReason() -> String? { private func blockReason() -> String? {
if isPaused { return "GrammaGem is paused." } if isPaused { return "GrammarGem is paused." }
let front = detector.frontmost() let front = detector.frontmost()
if exclusions.isBlocked(bundleID: front?.bundleID, domain: detector.frontmostDomain()) { if exclusions.isBlocked(bundleID: front?.bundleID, domain: detector.frontmostDomain()) {
return "GrammaGem is off for \(front?.name ?? "this app")." return "GrammarGem is off for \(front?.name ?? "this app")."
} }
return nil return nil
} }
@ -256,9 +384,9 @@ final class AppState: ObservableObject {
if onboardingWindow == nil { if onboardingWindow == nil {
let host = NSHostingController(rootView: OnboardingView().environmentObject(self)) let host = NSHostingController(rootView: OnboardingView().environmentObject(self))
let win = NSWindow(contentViewController: host) let win = NSWindow(contentViewController: host)
win.title = "Welcome to GrammaGem" win.title = "Welcome to GrammarGem"
win.styleMask = [.titled, .closable, .fullSizeContentView] win.styleMask = [.titled, .closable, .fullSizeContentView]
win.setContentSize(NSSize(width: 540, height: 600)) win.setContentSize(NSSize(width: 560, height: 720))
win.isReleasedWhenClosed = false win.isReleasedWhenClosed = false
win.center() win.center()
onboardingWindow = win onboardingWindow = win
@ -285,7 +413,7 @@ final class AppState: ObservableObject {
.environmentObject(liveMonitor) .environmentObject(liveMonitor)
let host = NSHostingController(rootView: root) let host = NSHostingController(rootView: root)
let win = NSWindow(contentViewController: host) let win = NSWindow(contentViewController: host)
win.title = "GrammaGem" win.title = "GrammarGem"
win.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] win.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView]
win.setContentSize(NSSize(width: 940, height: 660)) win.setContentSize(NSSize(width: 940, height: 660))
win.minSize = NSSize(width: 820, height: 560) win.minSize = NSSize(width: 820, height: 560)
@ -323,7 +451,7 @@ final class AppState: ObservableObject {
if askWindow == nil { if askWindow == nil {
let host = NSHostingController(rootView: AskView().environmentObject(self)) let host = NSHostingController(rootView: AskView().environmentObject(self))
let win = NSWindow(contentViewController: host) let win = NSWindow(contentViewController: host)
win.title = "Ask GrammaGem" win.title = "Ask GrammarGem"
win.styleMask = [.titled, .closable] win.styleMask = [.titled, .closable]
win.setContentSize(NSSize(width: 460, height: 220)) win.setContentSize(NSSize(width: 460, height: 220))
win.isReleasedWhenClosed = false win.isReleasedWhenClosed = false

View File

@ -8,7 +8,7 @@ struct AskView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
Text("Ask GrammaGem") Text("Ask GrammarGem")
.font(.headline) .font(.headline)
Text("Acts on the text selected in the frontmost app. Runs on-device.") Text("Acts on the text selected in the frontmost app. Runs on-device.")
.font(.caption) .font(.caption)

View File

@ -0,0 +1,76 @@
import SwiftUI
import AppKit
/// Process entry point. A hidden `--mlx-selftest` flag loads the on-device model
/// and runs one real generation (verifies the MLX runtime + metallib headlessly),
/// then exits. Otherwise the normal menu-bar app launches.
@main
enum GrammarGemMain {
static func main() {
if CommandLine.arguments.contains("--mlx-selftest") {
MLXSelfTest.run()
return
}
if let i = CommandLine.arguments.firstIndex(of: "--update-selftest") {
let feed = i + 1 < CommandLine.arguments.count ? CommandLine.arguments[i + 1] : ""
UpdaterSelfTest.run(feedURL: feed)
return
}
GrammarGemApp.main()
}
}
/// Headless verification of the on-device LLM (used by CI / the build pipeline).
enum MLXSelfTest {
static func run() {
let sample = "me and him was going to the store for to buy some milks"
let engine = MLXEngine(modelDirectoryProvider: {
ModelManager.completedModelDirectory(repo: ModelManager.activeRepo())
})
guard engine.isReady else {
FileHandle.standardError.write(Data("SELFTEST_FAIL: model not present\n".utf8))
exit(2)
}
let sem = DispatchSemaphore(value: 0)
var line = "SELFTEST_FAIL: unknown"
Task.detached {
do { line = "SELFTEST_OK: " + (try await engine.run(.rewriteClarity, on: sample)) }
catch { line = "SELFTEST_FAIL: \(error.localizedDescription)" }
sem.signal()
}
sem.wait()
print(line)
exit(line.hasPrefix("SELFTEST_OK") ? 0 : 1)
}
}
/// 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`).
struct GrammarGemApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@StateObject private var app = AppState.shared
var body: some Scene {
MenuBarExtra {
MenuBarContent()
.environmentObject(app)
} label: {
MenuBarLabel()
}
.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

@ -8,7 +8,7 @@ struct GeneralView: View {
@EnvironmentObject private var permissions: Permissions @EnvironmentObject private var permissions: Permissions
var body: some View { var body: some View {
DetailScaffold(title: "General", subtitle: "Make GrammaGem feel like yours.") { DetailScaffold(title: "General", subtitle: "Make GrammarGem feel like yours.") {
Card { Card {
Toggle("Launch at login", isOn: $prefs.launchAtLogin) Toggle("Launch at login", isOn: $prefs.launchAtLogin)
Divider() Divider()
@ -134,7 +134,7 @@ struct AboutView: View {
.frame(width: 56, height: 56) .frame(width: 56, height: 56)
.background(GG.emerald.opacity(0.12), in: RoundedRectangle(cornerRadius: 14)) .background(GG.emerald.opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("GrammaGem").font(.title2.bold()) Text("GrammarGem").font(.title2.bold())
Text("Version \(AppConfig.appVersion)").foregroundStyle(.secondary) Text("Version \(AppConfig.appVersion)").foregroundStyle(.secondary)
Text("The private, on-device writing assistant for Mac.") Text("The private, on-device writing assistant for Mac.")
.font(.callout).foregroundStyle(.secondary) .font(.callout).foregroundStyle(.secondary)
@ -153,7 +153,7 @@ struct AboutView: View {
Card { Card {
Text("Open source").font(.headline) Text("Open source").font(.headline)
Text("GrammaGem is open source under the MIT License — a privacy tool you can inspect.") Text("GrammarGem is open source under the MIT License — a privacy tool you can inspect.")
.font(.callout).foregroundStyle(.secondary) .font(.callout).foregroundStyle(.secondary)
} }

View File

@ -30,6 +30,15 @@ struct DashboardView: View {
StatTile(value: "\(usage.data.totalCorrections)", label: "Corrections made", systemImage: "checkmark.seal.fill", tint: GG.emerald) 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) StatTile(value: timeSaved, label: "Time saved (est.)", systemImage: "clock.fill", tint: .primary)
StatTile(
value: usage.currentStreak > 0 ? "\(usage.currentStreak)d" : "—",
label: "Day streak", systemImage: "flame.fill",
tint: usage.currentStreak > 0 ? .orange : .secondary)
StatTile(
value: "$\(usage.subscriptionAvoidedUSD)",
label: "Subscription not paid", systemImage: "dollarsign.circle.fill",
tint: GG.emerald)
} }
// Setup health // Setup health
@ -61,6 +70,19 @@ struct DashboardView: View {
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
} }
// Next milestone
Card {
let milestone = usage.nextMilestone
HStack {
Text("Next milestone").font(.headline)
Spacer()
Text(milestone.label).font(.caption).foregroundStyle(.secondary)
}
ProgressView(value: milestone.progress).tint(GG.emerald)
Text("\(usage.data.totalCorrections) corrections so far — keep the streak alive.")
.font(.caption).foregroundStyle(.secondary)
}
// Quick actions // Quick actions
Card { Card {
Text("Quick actions").font(.headline) Text("Quick actions").font(.headline)
@ -69,7 +91,7 @@ struct DashboardView: View {
Label("Fix selection", systemImage: "checkmark.circle") Label("Fix selection", systemImage: "checkmark.circle")
} }
Button { app.showAsk() } label: { Button { app.showAsk() } label: {
Label("Ask GrammaGem", systemImage: "sparkles") Label("Ask GrammarGem", systemImage: "sparkles")
} }
if !license.isLicensed { if !license.isLicensed {
Spacer() Spacer()

View File

@ -12,7 +12,7 @@ struct DictionaryView: View {
var body: some View { var body: some View {
DetailScaffold( DetailScaffold(
title: "Personal Dictionary", title: "Personal Dictionary",
subtitle: "Words GrammaGem should never “correct” — names, brands, jargon." subtitle: "Words GrammarGem should never “correct” — names, brands, jargon."
) { ) {
Card { Card {
HStack { HStack {

View File

@ -83,7 +83,7 @@ struct DevicesView: View {
HStack { HStack {
Link("Open license portal", destination: AppConfig.modelPortalURL) Link("Open license portal", destination: AppConfig.modelPortalURL)
Spacer() Spacer()
Text("Add a Mac: install GrammaGem there and paste the same key.") Text("Add a Mac: install GrammarGem there and paste the same key.")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
} }
} }

View File

@ -8,12 +8,12 @@ struct ExclusionsView: View {
var body: some View { var body: some View {
DetailScaffold( DetailScaffold(
title: "Page Blocker", title: "Page Blocker",
subtitle: "Keep GrammaGem out of the way in sensitive apps and sites." subtitle: "Keep GrammarGem out of the way in sensitive apps and sites."
) { ) {
Card { Card {
Label("How it works", systemImage: "hand.raised") Label("How it works", systemImage: "hand.raised")
.font(.headline) .font(.headline)
Text("When the frontmost app or website is on these lists, GrammaGem stops monitoring and the hotkeys do nothing — useful for password managers, banking, and private work.") Text("When the frontmost app or website is on these lists, GrammarGem stops monitoring and the hotkeys do nothing — useful for password managers, banking, and private work.")
.font(.callout).foregroundStyle(.secondary) .font(.callout).foregroundStyle(.secondary)
} }

View File

@ -34,22 +34,32 @@ struct LiveCheckView: View {
} }
private var header: some View { private var header: some View {
HStack(spacing: 12) { VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 2) { HStack(spacing: 12) {
Text(summary).font(.headline) ScoreRing(score: monitor.writingScore)
Text(monitor.activeAppName.isEmpty VStack(alignment: .leading, spacing: 2) {
? "Checking text areas on screen" Text(summary).font(.headline)
: "In \(monitor.activeAppName) · \(monitor.scannedCount) text area\(monitor.scannedCount == 1 ? "" : "s") checked") Text(contextLine).font(.caption).foregroundStyle(.secondary)
.font(.caption).foregroundStyle(.secondary) }
} Spacer()
Spacer() Button { monitor.refreshSoon() } label: { Image(systemName: "arrow.clockwise") }
Button { monitor.refreshSoon() } label: { Image(systemName: "arrow.clockwise") } .help("Re-scan now")
.help("Re-scan now") if monitor.issueCount > 0 {
if monitor.issueCount > 0 { Button { app.fixAllDetected() } label: {
Button { app.fixAllDetected() } label: { Label("Fix everything", systemImage: "checkmark.circle.fill")
Label("Fix everything", systemImage: "checkmark.circle.fill") }
.buttonStyle(.borderedProminent)
}
}
if monitor.issueCount > 0 {
HStack(spacing: 8) {
ForEach(IssueBucket.allCases) { bucket in
if let n = monitor.bucketCounts[bucket], n > 0 {
BucketChip(bucket: bucket, count: n)
}
}
Spacer()
} }
.buttonStyle(.borderedProminent)
} }
} }
.padding(16) .padding(16)
@ -57,10 +67,16 @@ struct LiveCheckView: View {
private var summary: String { private var summary: String {
let issues = monitor.issueCount let issues = monitor.issueCount
if issues == 0 { return "No issues found" } if issues == 0 { return "Looks clean" }
return "\(issues) issue\(issues == 1 ? "" : "s") across \(monitor.fields.count) text area\(monitor.fields.count == 1 ? "" : "s")" return "\(issues) issue\(issues == 1 ? "" : "s") across \(monitor.fields.count) text area\(monitor.fields.count == 1 ? "" : "s")"
} }
private var contextLine: String {
monitor.activeAppName.isEmpty
? "Checking text areas on screen"
: "In \(monitor.activeAppName) · \(monitor.scannedCount) text area\(monitor.scannedCount == 1 ? "" : "s") checked"
}
private var emptyState: some View { private var emptyState: some View {
VStack(spacing: 10) { VStack(spacing: 10) {
Image(systemName: "checkmark.seal.fill").font(.system(size: 42)).foregroundStyle(GG.emerald) Image(systemName: "checkmark.seal.fill").font(.system(size: 42)).foregroundStyle(GG.emerald)
@ -112,9 +128,21 @@ private struct FieldCard: View {
} }
} }
Spacer() Spacer()
if s.kind == .spelling {
Button { app.addWordToDictionary(s.original) } label: {
Image(systemName: "book.closed")
}
.controlSize(.small).buttonStyle(.borderless)
.help("Add “\(s.original)” to your dictionary")
}
Button { app.dismissSuggestion(s, in: field) } label: {
Image(systemName: "xmark")
}
.controlSize(.small).buttonStyle(.borderless)
.help("Dismiss this suggestion")
if s.replacement != s.original && !s.replacement.isEmpty { if s.replacement != s.original && !s.replacement.isEmpty {
Button("Apply") { app.applySuggestion(s, in: field) } Button("Apply") { app.applySuggestion(s, in: field) }
.controlSize(.small) .controlSize(.small).buttonStyle(.borderedProminent)
} }
} }
} }
@ -141,6 +169,14 @@ private struct FieldCard: View {
} }
} }
fileprivate static func color(for bucket: IssueBucket) -> Color {
switch bucket {
case .correctness: return .red
case .clarity: return .blue
case .polish: return GG.gold
}
}
/// Build an attributed string with each issue span tinted. /// Build an attributed string with each issue span tinted.
private func highlighted(_ text: String, _ suggestions: [Suggestion]) -> AttributedString { private func highlighted(_ text: String, _ suggestions: [Suggestion]) -> AttributedString {
let ns = text as NSString let ns = text as NSString
@ -167,3 +203,40 @@ private struct FieldCard: View {
return result return result
} }
} }
/// A compact 0–100 cleanliness gauge. Green when clean, amber/orange as issues
/// pile up — a meter the user can watch climb as they accept fixes.
private struct ScoreRing: View {
let score: Int
var body: some View {
ZStack {
Circle().stroke(Color.primary.opacity(0.1), lineWidth: 5)
Circle()
.trim(from: 0, to: CGFloat(max(0, min(100, score))) / 100)
.stroke(color, style: StrokeStyle(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.easeOut(duration: 0.35), value: score)
Text("\(score)").font(.system(size: 15, weight: .bold, design: .rounded))
}
.frame(width: 44, height: 44)
.help("Writing score for the text on screen")
}
private var color: Color {
score >= 90 ? GG.emerald : (score >= 70 ? GG.gold : .orange)
}
}
/// A tappable-looking count chip per issue bucket (Correctness / Clarity / Polish).
private struct BucketChip: View {
let bucket: IssueBucket
let count: Int
var body: some View {
HStack(spacing: 5) {
Circle().fill(FieldCard.color(for: bucket)).frame(width: 7, height: 7)
Text("\(bucket.title) \(count)").font(.caption.weight(.medium))
}
.padding(.horizontal, 9).padding(.vertical, 4)
.background(FieldCard.color(for: bucket).opacity(0.12), in: Capsule())
}
}

View File

@ -59,7 +59,7 @@ enum MainSection: String, CaseIterable, Identifiable {
] ]
} }
/// The full GrammaGem management window: a clean sidebar + detail layout. /// The full GrammarGem management window: a clean sidebar + detail layout.
struct MainWindowView: View { struct MainWindowView: View {
@State private var selection: MainSection? @State private var selection: MainSection?

View File

@ -17,7 +17,7 @@ struct ShortcutsView: View {
combo: $prefs.fixHotkey) combo: $prefs.fixHotkey)
Divider() Divider()
shortcutRow( shortcutRow(
name: "Ask GrammaGem", name: "Ask GrammarGem",
detail: "Rewrite with an instruction", detail: "Rewrite with an instruction",
combo: $prefs.askHotkey) combo: $prefs.askHotkey)
} }
@ -141,7 +141,7 @@ struct PrivacyView: View {
} }
Card { Card {
Text("The only times GrammaGem uses the network").font(.headline) Text("The only times GrammarGem uses the network").font(.headline)
networkRow("One-time AI model download", "Hugging Face, on first run") networkRow("One-time AI model download", "Hugging Face, on first run")
Divider() Divider()
networkRow("License activation & checks", "To enforce your device cap") networkRow("License activation & checks", "To enforce your device cap")

View File

@ -15,7 +15,7 @@ struct WritingModesView: View {
var body: some View { var body: some View {
DetailScaffold( DetailScaffold(
title: "Writing Modes", title: "Writing Modes",
subtitle: "Tune the tone for every context — or let GrammaGem pick it for you." subtitle: "Tune the tone for every context — or let GrammarGem pick it for you."
) { ) {
builtInCard builtInCard
customCard customCard
@ -88,7 +88,7 @@ struct WritingModesView: View {
private var appAwareCard: some View { private var appAwareCard: some View {
Card { Card {
HStack { Text("App-aware switching").font(.headline); if !paid { UpgradeBadge() } } 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.") Text("Map an app to a Mode and GrammarGem applies it automatically when that app is in front.")
.font(.callout).foregroundStyle(.secondary) .font(.callout).foregroundStyle(.secondary)
if !customModes.appOverrides.isEmpty { if !customModes.appOverrides.isEmpty {

View File

@ -24,16 +24,22 @@ struct MenuBarLabel: View {
struct MenuBarContent: View { struct MenuBarContent: View {
@EnvironmentObject private var app: AppState @EnvironmentObject private var app: AppState
@ObservedObject private var monitor: LiveMonitor @ObservedObject private var monitor: LiveMonitor
@ObservedObject private var updater: UpdaterManager
@Environment(\.openSettings) private var openSettings @Environment(\.openSettings) private var openSettings
init() { init() {
_monitor = ObservedObject(wrappedValue: AppState.shared.liveMonitor) _monitor = ObservedObject(wrappedValue: AppState.shared.liveMonitor)
_updater = ObservedObject(wrappedValue: AppState.shared.updater)
} }
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
header header
if let version = updater.updateAvailableVersion {
updateBanner(version: version)
}
Divider() Divider()
liveStatus liveStatus
@ -43,7 +49,7 @@ struct MenuBarContent: View {
actionRow(title: "Fix selection", shortcut: app.preferences.fixHotkey.display) { actionRow(title: "Fix selection", shortcut: app.preferences.fixHotkey.display) {
Task { await app.runFix() } Task { await app.runFix() }
} }
actionRow(title: "Ask GrammaGem…", shortcut: app.preferences.askHotkey.display) { actionRow(title: "Ask GrammarGem…", shortcut: app.preferences.askHotkey.display) {
app.showAsk() app.showAsk()
} }
if app.gate.appAwareEnabled { if app.gate.appAwareEnabled {
@ -51,20 +57,22 @@ struct MenuBarContent: View {
Task { await app.runAppAwareRewrite() } Task { await app.runAppAwareRewrite() }
} }
} }
actionRow(title: app.isPaused ? "Resume GrammaGem" : "Pause GrammaGem", shortcut: nil) { actionRow(title: app.isPaused ? "Resume GrammarGem" : "Pause GrammarGem", shortcut: nil) {
app.togglePause() app.togglePause()
} }
Divider() Divider()
actionRow(title: "Show live issues…", shortcut: nil) { app.showLiveCheck() } actionRow(title: "Show live issues…", shortcut: nil) { app.showLiveCheck() }
actionRow(title: "Open GrammaGem…", shortcut: nil) { app.showMainWindow() } actionRow(title: "Open GrammarGem…", shortcut: nil) { app.showMainWindow() }
actionRow(title: "Manage devices…", shortcut: nil) { app.showMainWindow(select: .devices) } actionRow(title: "Manage devices…", shortcut: nil) { app.showMainWindow(select: .devices) }
actionRow(title: "Page blocker…", shortcut: nil) { app.showMainWindow(select: .exclusions) } actionRow(title: "Page blocker…", shortcut: nil) { app.showMainWindow(select: .exclusions) }
actionRow(title: "Check for Updates…", shortcut: nil) { app.updater.checkForUpdates() }
Divider() Divider()
usageRow usageRow
valueRow
Divider() Divider()
@ -82,11 +90,27 @@ struct MenuBarContent: View {
.frame(width: 290) .frame(width: 290)
} }
/// Shown when Sparkle has found a newer signed build on grammargem.com.
private func updateBanner(version: String) -> some View {
HStack(spacing: 8) {
Image(systemName: "arrow.down.circle.fill").foregroundStyle(GG.emerald)
VStack(alignment: .leading, spacing: 1) {
Text("Update available").font(.callout.weight(.semibold))
Text("Version \(version) is ready to install").font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button("Update") { app.updater.checkForUpdates() }
.buttonStyle(.borderedProminent).controlSize(.small)
}
.padding(8)
.background(GG.emerald.opacity(0.10), in: RoundedRectangle(cornerRadius: 8))
}
private var header: some View { private var header: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "pencil.and.scribble").foregroundStyle(.tint) Image(systemName: "pencil.and.scribble").foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
Text("GrammaGem").font(.headline) Text("GrammarGem").font(.headline)
Text(app.license.tier.displayName + (app.license.isLicensed ? " · licensed" : " · free")) Text(app.license.tier.displayName + (app.license.isLicensed ? " · licensed" : " · free"))
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
} }
@ -152,6 +176,16 @@ struct MenuBarContent: View {
} }
} }
/// One always-visible value reminder: the streak + lifetime corrections.
@ViewBuilder
private var valueRow: some View {
if app.usage.currentStreak > 1 {
Label("\(app.usage.currentStreak)-day streak · \(app.usage.data.totalCorrections) corrections",
systemImage: "flame.fill")
.font(.caption).foregroundStyle(.secondary)
}
}
private func actionRow(title: String, shortcut: String?, action: @escaping () -> Void) -> some View { private func actionRow(title: String, shortcut: String?, action: @escaping () -> Void) -> some View {
Button(action: action) { Button(action: action) {
HStack { HStack {

View File

@ -0,0 +1,220 @@
import SwiftUI
/// First-run onboarding: explains why GrammarGem needs Accessibility, deep-links
/// to System Settings, and advances automatically the moment permission is
/// granted (even if granted directly in System Settings).
struct OnboardingView: View {
@EnvironmentObject private var app: AppState
@ObservedObject private var permissions: Permissions
@ObservedObject private var model: ModelManager
init() {
_permissions = ObservedObject(wrappedValue: AppState.shared.permissions)
_model = ObservedObject(wrappedValue: AppState.shared.model)
}
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 6) {
Text("Welcome to GrammarGem")
.font(.largeTitle.bold())
Text("A private, on-device writing assistant that works in every app.")
.foregroundStyle(.secondary)
}
// The rigged win: prove the value in one click, before any ask.
VStack(alignment: .leading, spacing: 10) {
Label("See it work", systemImage: "wand.and.stars").font(.headline)
DemoStep()
}
.padding(16)
.background(GG.emerald.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(GG.emerald.opacity(0.25)))
stepCard(
number: 1,
title: "Turn it on everywhere",
body: "GrammarGem 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: permissions.accessibilityTrusted
) {
if permissions.accessibilityTrusted {
Label("Permission granted", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
} else {
HStack {
Button("Open System Settings") { permissions.openAccessibilitySettings() }
Button("Request permission") { permissions.requestAccessibility() }
}
Text("This updates automatically once you flip GrammarGem on in Accessibility.")
.font(.caption).foregroundStyle(.secondary)
}
}
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: model.state == .ready
) {
ModelDownloadControls()
}
}
.padding(28)
}
Divider()
HStack {
Label("Yours once. No subscription, nothing leaves your Mac.", systemImage: "lock.shield")
.font(.caption).foregroundStyle(.secondary)
Spacer()
Button(permissions.accessibilityTrusted ? "Done" : "Continue without permission") {
app.showOnboardingDismiss()
}
.keyboardShortcut(.defaultAction)
}
.padding(.horizontal, 28).padding(.vertical, 14)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear {
permissions.startPollingUntilGranted()
}
.onDisappear {
permissions.stopPolling()
}
}
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))
}
}
/// A safe, on-rails first-run demo: a local sample with planted mistakes that
/// the REAL grammar engine fixes in one click. It operates on a plain string
/// (never a live Accessibility element), so it's completely risk-free — the
/// "guaranteed visible win" before GrammarGem asks for anything.
private struct DemoStep: View {
@State private var text = "i has wrote alot of mistakes here, and it dont look profesional"
@State private var issues: [Suggestion] = []
@State private var cleaned = false
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(headline)
.font(.subheadline.weight(.semibold))
.foregroundStyle(cleaned ? GG.emerald : .primary)
Text(highlighted)
.font(.callout)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
.animation(.easeInOut(duration: 0.35), value: text)
Button {
let fixed = AppState.shared.grammar.correct(text)
withAnimation(.easeOut(duration: 0.4)) {
text = fixed
issues = []
cleaned = true
}
} label: {
Label(cleaned ? "All clean" : "Clean it up",
systemImage: cleaned ? "checkmark.circle.fill" : "wand.and.stars")
}
.buttonStyle(.borderedProminent)
.disabled(cleaned || issues.isEmpty)
}
.onAppear {
if issues.isEmpty && !cleaned { issues = AppState.shared.grammar.check(text) }
}
}
private var headline: String {
if cleaned { return "Clean ✨ — GrammarGem does this in every app you type." }
if issues.isEmpty { return "Checking…" }
return "We spotted \(issues.count) thing\(issues.count == 1 ? "" : "s") to tidy up — one click fixes them."
}
private var highlighted: AttributedString {
let ns = text as NSString
var result = AttributedString()
var cursor = 0
for s in issues.sorted(by: { $0.location < $1.location }) {
guard s.location >= cursor, s.length > 0, s.location + s.length <= ns.length else { continue }
if s.location > cursor {
result += AttributedString(ns.substring(with: NSRange(location: cursor, length: s.location - cursor)))
}
var run = AttributedString(ns.substring(with: s.range))
let tint: Color = s.kind == .spelling ? .red : .orange
run.backgroundColor = tint.opacity(0.18)
run.foregroundColor = tint
run.underlineStyle = .single
result += run
cursor = s.location + s.length
}
if cursor < ns.length {
result += AttributedString(ns.substring(with: NSRange(location: cursor, length: ns.length - cursor)))
}
return result
}
}
/// Real model download progress + controls, shared by onboarding and settings.
struct ModelDownloadControls: View {
@ObservedObject private var model: ModelManager
init() {
_model = ObservedObject(wrappedValue: AppState.shared.model)
}
var body: some View {
switch model.state {
case .notDownloaded:
VStack(alignment: .leading, spacing: 4) {
Button("Download model now") { model.startDownload() }
Text("Installs automatically in the background on first launch (about 1–2 GB from Hugging Face). One time, then fully offline.")
.font(.caption).foregroundStyle(.secondary)
}
case .downloading(let progress):
VStack(alignment: .leading, spacing: 6) {
ProgressView(value: progress) {
Text("Downloading… \(Int(progress * 100))%")
}
.frame(maxWidth: 320)
if !model.statusText.isEmpty {
Text(model.statusText).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Button("Cancel") { model.cancel() }.controlSize(.small)
}
case .ready:
Label("Model ready", systemImage: "checkmark.seal.fill").foregroundStyle(.green)
case .failed(let message):
VStack(alignment: .leading, spacing: 4) {
Label("Download failed", systemImage: "exclamationmark.triangle").foregroundStyle(.orange)
Text(message).font(.caption).foregroundStyle(.secondary).lineLimit(2)
Button("Retry") { model.startDownload() }
}
}
}
}

View File

@ -25,7 +25,7 @@ private struct GeneralSettings: View {
Form { Form {
Section("Shortcuts") { Section("Shortcuts") {
LabeledContent("Fix selection", value: "⌘;") LabeledContent("Fix selection", value: "⌘;")
LabeledContent("Ask GrammaGem", value: "⌘'") LabeledContent("Ask GrammarGem", value: "⌘'")
Text("Custom shortcuts coming via the KeyboardShortcuts recorder (see README).") Text("Custom shortcuts coming via the KeyboardShortcuts recorder (see README).")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
} }
@ -147,7 +147,7 @@ private struct DictionarySettings: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack { HStack {
TextField("Add a word GrammaGem should never correct", text: $newWord) TextField("Add a word GrammarGem should never correct", text: $newWord)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.onSubmit(add) .onSubmit(add)
Button("Add", action: add).disabled(newWord.trimmingCharacters(in: .whitespaces).isEmpty) Button("Add", action: add).disabled(newWord.trimmingCharacters(in: .whitespaces).isEmpty)

View File

@ -1,15 +1,15 @@
import Foundation import Foundation
/// Compile-time configuration. The Lemon Squeezy identifiers are hard-coded so /// 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 licensing layer can *hard-verify* that a key belongs to GrammarGem (and to
/// the claimed tier) rather than to some other Lemon Squeezy product. /// the claimed tier) rather than to some other Lemon Squeezy product.
/// TODO(real-integration): replace every `TODO_*` value with your real IDs. /// TODO(real-integration): replace every `TODO_*` value with your real IDs.
enum AppConfig { enum AppConfig {
static let appName = "GrammaGem" static let appName = "GrammarGem"
static let bundleIdentifier = "com.foundergem.grammagem" // TODO(real-integration) static let bundleIdentifier = "com.foundergem.grammargem" // TODO(real-integration)
static let supportEmail = "support@grammagem.app" // TODO(real-integration) static let supportEmail = "support@grammargem.com" // TODO(real-integration)
static let modelPortalURL = URL(string: "https://grammagem.app/license")! // TODO static let modelPortalURL = URL(string: "https://www.grammargem.com/license")! // TODO
static let websiteURL = URL(string: "https://grammagem.app")! static let websiteURL = URL(string: "https://www.grammargem.com")!
static let appVersion = "0.1.0" static let appVersion = "0.1.0"
// Legal entity (shown in About / legal). // Legal entity (shown in About / legal).
@ -37,6 +37,23 @@ enum AppConfig {
static let huggingFaceBase = "https://huggingface.co" static let huggingFaceBase = "https://huggingface.co"
} }
/// The cross-platform Layer-2 backend: a locally-running Ollama server. Used
/// when MLX isn't available (Windows/Linux) or when the user opts in. Inference
/// stays on-device — Ollama runs the model on the user's own machine.
enum Ollama {
static let baseURL = URL(string: "http://127.0.0.1:11434")!
static let defaultModel = "qwen2.5:3b" // matches the MLX default model family
}
/// Loopback engine server for the browser extension (off by default; see
/// `LocalEngineServer`). Exposes Harper + the local LLM to the extension over
/// 127.0.0.1 with a per-install token — no cloud round-trip.
enum LocalServer {
static let port: UInt16 = 47800
static let tokenKey = "GrammarGem.localServerToken"
static let enableKey = "GrammarGem.enableLocalServer"
}
/// Free-tier limits + license validation cadence (see spec §5/§6). /// Free-tier limits + license validation cadence (see spec §5/§6).
enum Limits { enum Limits {
static let freeDailyAIActions = 10 static let freeDailyAIActions = 10

View File

@ -42,6 +42,42 @@ enum GrammarKind: String, Codable {
case phrasing case phrasing
case repetition case repetition
case capitalization case capitalization
/// Severity weight — drives card ordering and the writing-score penalty.
/// Objective errors (spelling, grammar) weigh most.
var severity: Int {
switch self {
case .spelling, .grammar: return 3
case .punctuation, .capitalization: return 2
case .repetition, .phrasing: return 1
}
}
/// User-facing grouping shown as filter chips in the Live Check panel.
var bucket: IssueBucket {
switch self {
case .spelling, .grammar, .punctuation: return .correctness
case .phrasing: return .clarity
case .repetition, .capitalization: return .polish
}
}
}
/// Our own named issue groups (deliberately NOT Grammarly's exact buckets/words):
/// objective errors, clarity rewrites, and stylistic polish.
enum IssueBucket: String, CaseIterable, Identifiable {
case correctness
case clarity
case polish
var id: String { rawValue }
var title: String {
switch self {
case .correctness: return "Correctness"
case .clarity: return "Clarity"
case .polish: return "Polish"
}
}
} }
/// A single grammar suggestion over a span of the input text. /// A single grammar suggestion over a span of the input text.

View File

@ -72,7 +72,7 @@ final class FeatureGate: ObservableObject {
/// Free-tier AI action counter. Persists `{date, count}` in UserDefaults and /// Free-tier AI action counter. Persists `{date, count}` in UserDefaults and
/// resets at local midnight (compares the stored local date string to today's). /// resets at local midnight (compares the stored local date string to today's).
final class DailyCounter { final class DailyCounter {
private let key = "GrammaGem.aiActions.daily" private let key = "GrammarGem.aiActions.daily"
private let defaults = UserDefaults.standard private let defaults = UserDefaults.standard
private static let formatter: DateFormatter = { private static let formatter: DateFormatter = {

View File

@ -19,9 +19,15 @@ protocol GrammarEngine: AnyObject {
extension GrammarEngine { extension GrammarEngine {
/// Default correction: apply suggestions right-to-left so offsets stay valid. /// Default correction: apply suggestions right-to-left so offsets stay valid.
///
/// No-op flags (where the replacement equals the original — e.g. a grammar
/// issue Harper can describe but not auto-fix) are skipped, so "Fix all" /
/// the in-place hotkey only ever apply real, text-changing corrections and
/// never silently rewrite a span to itself.
func correct(_ text: String) -> String { func correct(_ text: String) -> String {
let ns = NSMutableString(string: text) let ns = NSMutableString(string: text)
for s in check(text).sorted(by: { $0.location > $1.location }) { for s in check(text).sorted(by: { $0.location > $1.location }) {
guard s.replacement != s.original else { continue }
guard s.location + s.length <= ns.length else { continue } guard s.location + s.length <= ns.length else { continue }
ns.replaceCharacters(in: s.range, with: s.replacement) ns.replaceCharacters(in: s.range, with: s.replacement)
} }

View File

@ -0,0 +1,83 @@
import Foundation
import CHarper
/// Layer 1 — the real **Harper** grammar core (Apache-2.0), embedded via a Rust
/// C-FFI static lib (`harper-ffi/`, harper-core 2.5.0). Fully offline,
/// deterministic, and fast — real subject-verb agreement, verb forms,
/// punctuation, repetition, capitalization and spelling, not just a spell check.
///
/// `harper_check` returns a JSON array of spans with **char (Unicode scalar)**
/// offsets; we map those onto UTF-16 `NSRange` offsets so they compose with the
/// rest of the app (AX ranges, `NSMutableString`).
final class HarperEngine: GrammarEngine {
var ignoreList: Set<String> = []
func check(_ text: String) -> [Suggestion] {
guard !text.isEmpty else { return [] }
// Call into Rust. The returned C string is heap-allocated by Harper and
// must be freed with harper_free (it outlives the withCString buffer).
guard let raw = text.withCString({ harper_check($0) }) else { return [] }
defer { harper_free(raw) }
let json = String(cString: raw)
guard let data = json.data(using: .utf8),
let lints = try? JSONDecoder().decode([HarperLint].self, from: data)
else { return [] }
// Harper can emit several lints over the SAME span (e.g. two phrasings of
// "checkin" -> "check in"). Keep the first (highest-priority) per span so
// the right-to-left bulk apply in `correct()` never double-writes a range.
var kept: [Suggestion] = []
for lint in lints {
guard lint.len > 0 || !lint.replacement.isEmpty else { continue }
guard let range = Self.utf16Range(scalarStart: lint.start, scalarLen: lint.len, in: text)
else { continue }
let original = (text as NSString).substring(with: range)
if !original.isEmpty, ignoreList.contains(original.lowercased()) { continue }
if kept.contains(where: { NSIntersectionRange($0.range, range).length > 0 }) { continue }
kept.append(Suggestion(
location: range.location, length: range.length,
original: original, replacement: lint.replacement,
kind: GrammarKind(harperKind: lint.kind), message: lint.message))
}
return kept.sorted { $0.location < $1.location }
}
// MARK: - FFI decoding
private struct HarperLint: Decodable {
let start: Int
let len: Int
let replacement: String
let message: String
let kind: String
}
/// Map a Harper char-span (Unicode scalar offsets, end-exclusive) onto a
/// UTF-16 `NSRange`. Scalar boundaries are always UTF-16 boundaries, so the
/// conversion is exact.
static func utf16Range(scalarStart: Int, scalarLen: Int, in text: String) -> NSRange? {
let scalars = text.unicodeScalars
guard let s = scalars.index(scalars.startIndex, offsetBy: scalarStart, limitedBy: scalars.endIndex),
let e = scalars.index(s, offsetBy: scalarLen, limitedBy: scalars.endIndex)
else { return nil }
let location = text.utf16.distance(from: text.utf16.startIndex, to: s)
let length = text.utf16.distance(from: s, to: e)
return NSRange(location: location, length: length)
}
}
extension GrammarKind {
/// Map Harper's `kind` string (see harper.h) onto our category enum.
init(harperKind kind: String) {
switch kind {
case "spelling": self = .spelling
case "punctuation": self = .punctuation
case "repetition": self = .repetition
case "capitalization": self = .capitalization
case "phrasing": self = .phrasing
default: self = .grammar
}
}
}

View File

@ -1,13 +1,13 @@
import Foundation import Foundation
/// The user's personal dictionary — names, brand terms, jargon GrammaGem should /// The user's personal dictionary — names, brand terms, jargon GrammarGem should
/// never "correct". Feeds Harper's ignore-list and the LLM context. Capped at 25 /// 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). /// entries on free, unlimited on paid (enforced by `FeatureGate`, not here).
@MainActor @MainActor
final class PersonalDictionary: ObservableObject { final class PersonalDictionary: ObservableObject {
@Published private(set) var entries: [String] @Published private(set) var entries: [String]
private let key = "GrammaGem.personalDictionary" private let key = "GrammarGem.personalDictionary"
private let defaults = UserDefaults.standard private let defaults = UserDefaults.standard
init() { init() {

View File

@ -0,0 +1,90 @@
import Foundation
import AppKit
/// Layer 1 orchestrator. The real **Harper** core is the primary engine
/// (grammar, punctuation, repetition, capitalization, spelling); macOS's
/// `NSSpellChecker` is layered on top purely for *extra spelling coverage* from
/// its large system dictionary.
///
/// Two correctness rules make the combination trustworthy:
/// 1. **Harper wins on overlap** — a system spelling guess is dropped if Harper
/// already flagged the same span (Harper's fixes carry correct casing + a
/// real explanation).
/// 2. **Case-preserving** — a system spelling replacement is matched to the
/// original token's casing, so a mid-sentence word is never spuriously
/// capitalized (the historical `problemm` -> `Problem` bug). Genuine
/// sentence-start capitalization is surfaced separately by Harper.
final class SystemGrammarEngine: GrammarEngine {
var ignoreList: Set<String> = [] {
didSet { harper.ignoreList = ignoreList }
}
private let checker = NSSpellChecker.shared
private let tag = NSSpellChecker.uniqueSpellDocumentTag()
private let harper = HarperEngine()
func check(_ text: String) -> [Suggestion] {
guard text.count >= 2 else { return [] }
var out = harper.check(text)
// Supplement with NSSpellChecker spelling only (Harper owns grammar),
// de-duped against Harper spans and case-matched to the original token.
let spelling = onMain { self.spellingSupplement(text) }
for s in spelling where !out.contains(where: { overlaps($0.range, s.range) }) {
out.append(s)
}
return out.sorted { $0.location < $1.location }
}
private func spellingSupplement(_ text: String) -> [Suggestion] {
let ns = text as NSString
let full = NSRange(location: 0, length: ns.length)
let language = checker.language()
let results = checker.check(
text, range: full, types: NSTextCheckingResult.CheckingType.spelling.rawValue,
options: nil, inSpellDocumentWithTag: tag, orthography: nil, wordCount: nil)
var out: [Suggestion] = []
for r in results where r.resultType == .spelling {
let word = ns.substring(with: r.range)
if ignoreList.contains(word.lowercased()) { continue }
guard let raw = checker.correction(
forWordRange: r.range, in: text, language: language, inSpellDocumentWithTag: tag)
?? checker.guesses(
forWordRange: r.range, in: text, language: language,
inSpellDocumentWithTag: tag)?.first
else { continue }
let fix = Self.matchingCase(of: word, in: raw)
guard fix != word else { continue } // never emit a no-op
out.append(Suggestion(
location: r.range.location, length: r.range.length,
original: word, replacement: fix,
kind: .spelling, message: "Possible spelling mistake"))
}
return out
}
/// Re-case `replacement` to follow `original`'s casing.
static func matchingCase(of original: String, in replacement: String) -> String {
guard let o = original.first, let r = replacement.first else { return replacement }
// ALL-CAPS original (with at least one cased letter) -> upper the whole thing.
if original.count > 1, original == original.uppercased(), original != original.lowercased() {
return replacement.uppercased()
}
if o.isLowercase, r.isUppercase {
return r.lowercased() + replacement.dropFirst()
}
if o.isUppercase, r.isLowercase {
return r.uppercased() + replacement.dropFirst()
}
return replacement
}
private func overlaps(_ a: NSRange, _ b: NSRange) -> Bool {
NSIntersectionRange(a, b).length > 0
}
private func onMain<T>(_ work: @escaping () -> T) -> T {
Thread.isMainThread ? work() : DispatchQueue.main.sync(execute: work)
}
}

View File

@ -0,0 +1,20 @@
import Foundation
/// A deterministic, on-device "cleanliness" score (0–100) for a chunk of text.
///
/// Honest by construction: it's defined relative to *this* text — weighted issues
/// per 100 words — never a hidden cloud cohort, and grammar/spelling are always
/// fully resolvable, so a clean document genuinely reaches 100. (Contrast with
/// Grammarly's percentile-vs-other-users score, which needs telemetry + an
/// account; see docs/grammarly-value-plan.md §5.)
enum WritingScore {
/// - Parameters:
/// - weightedIssues: sum of `GrammarKind.severity` across outstanding issues.
/// - words: total words in the scanned text.
static func score(weightedIssues: Int, words: Int) -> Int {
guard words > 0 else { return 100 }
let perHundredWords = Double(weightedIssues) / Double(words) * 100.0
// Each weighted issue per 100 words costs ~4 points; clamp to 0…100.
return max(0, min(100, Int((100.0 - perHundredWords * 4.0).rounded())))
}
}

View File

@ -38,7 +38,7 @@ enum DeviceFingerprint {
} }
private static func fallbackID() -> String { private static func fallbackID() -> String {
let key = "GrammaGem.fallbackDeviceID" let key = "GrammarGem.fallbackDeviceID"
if let existing = UserDefaults.standard.string(forKey: key) { return existing } if let existing = UserDefaults.standard.string(forKey: key) { return existing }
let id = UUID().uuidString let id = UUID().uuidString
UserDefaults.standard.set(id, forKey: key) UserDefaults.standard.set(id, forKey: key)

View File

@ -2,7 +2,7 @@ import Foundation
/// Talks to the **Lemon Squeezy License API** directly from the app — no server /// 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 /// 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. /// / deactivate and hard-verifies that a key belongs to GrammarGem + the claimed tier.
final class LemonSqueezyClient { final class LemonSqueezyClient {
private let session: URLSession private let session: URLSession
private let base = AppConfig.LemonSqueezy.apiBase private let base = AppConfig.LemonSqueezy.apiBase
@ -54,20 +54,28 @@ final class LemonSqueezyClient {
// MARK: - Hard verification (spec §6) // MARK: - Hard verification (spec §6)
/// Confirm the key is for OUR store/product, then resolve which tier its /// 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. /// variant corresponds to. Prevents a key from another LS product unlocking GrammarGem.
private func verifyAndResolveTier(meta: LSMeta?, licenseKey: LSLicenseKey?) throws -> Tier { private func verifyAndResolveTier(meta: LSMeta?, licenseKey: LSLicenseKey?) throws -> Tier {
guard let meta else { throw LicenseError.wrongProduct } guard let meta else { throw LicenseError.wrongProduct }
// Store + product check. If AppConfig still holds placeholder (non-numeric) // Store + product check. With real numeric IDs configured this is a hard
// IDs, we log and skip the check so the stub flow runs — TODO(real-integration): // gate that prevents a key from another Lemon Squeezy product unlocking
// once real numeric IDs are set, this becomes a hard gate. // GrammarGem. If AppConfig still holds placeholder (non-numeric) IDs the
// gate can't run — allow the bypass ONLY in DEBUG so local stub flows work;
// in a release build FAIL CLOSED so the "any key unlocks paid" bypass can
// never ship. TODO(real-integration): set real IDs in AppConfig.LemonSqueezy.
if let expectedStore = Int(AppConfig.LemonSqueezy.expectedStoreID), if let expectedStore = Int(AppConfig.LemonSqueezy.expectedStoreID),
let expectedProduct = Int(AppConfig.LemonSqueezy.expectedProductID) { let expectedProduct = Int(AppConfig.LemonSqueezy.expectedProductID) {
guard meta.store_id == expectedStore, meta.product_id == expectedProduct else { guard meta.store_id == expectedStore, meta.product_id == expectedProduct else {
throw LicenseError.wrongProduct throw LicenseError.wrongProduct
} }
} else { } else {
Log.licensing.warning("Skipping store/product hard-verify — placeholder IDs in AppConfig.") #if DEBUG
Log.licensing.warning("Skipping store/product hard-verify — placeholder IDs in AppConfig (DEBUG only).")
#else
Log.licensing.error("Refusing to verify license: placeholder Lemon Squeezy IDs in a release build.")
throw LicenseError.wrongProduct
#endif
} }
// Resolve tier from the variant id (reverse-lookup the configured map). // Resolve tier from the variant id (reverse-lookup the configured map).

View File

@ -62,7 +62,7 @@ struct LSLicenseKey: Codable {
} }
/// `meta` carries the store/product/variant identity we *hard-verify* against /// `meta` carries the store/product/variant identity we *hard-verify* against
/// AppConfig so a key from another Lemon Squeezy product can't unlock GrammaGem. /// AppConfig so a key from another Lemon Squeezy product can't unlock GrammarGem.
struct LSMeta: Codable { struct LSMeta: Codable {
let store_id: Int? let store_id: Int?
let product_id: Int? let product_id: Int?

View File

@ -0,0 +1,154 @@
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 activeDays = 0 // distinct days with ≥1 action (lifetime)
var firstUseDay: String? // "yyyy-MM-dd" of first ever action
var perDay: [String: Int] = [:] // "yyyy-MM-dd" -> actions that day
}
/// What a Grammarly-style subscription runs per year (USD) — used only for the
/// honest, local "subscription you didn't pay" figure. GrammarGem is one-time.
static let comparableAnnualSubscriptionUSD = 144.0
@Published private(set) var data: Snapshot
private let key = "GrammarGem.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
}
// MARK: - Value loop (streak / milestones / subscription-you-didn't-pay)
/// Consecutive days, ending today or yesterday, with at least one action.
var currentStreak: Int { Self.streak(perDay: data.perDay, asOf: Date()) }
/// The Grammarly subscription you DIDN'T pay: their annual price prorated over
/// how long you've owned GrammarGem. Honest, local, illustrative — and it only
/// grows, which is the point of a one-time purchase (see the value plan §5).
var subscriptionAvoidedUSD: Int {
Self.subscriptionAvoidedUSD(
firstUseDay: data.firstUseDay, asOf: Date(),
annualPrice: Self.comparableAnnualSubscriptionUSD)
}
/// The next correction milestone and progress toward it (for a nudge/badge).
var nextMilestone: (target: Int, label: String, progress: Double) {
Self.nextMilestone(corrections: data.totalCorrections)
}
// MARK: - Pure helpers (unit-testable; no UserDefaults / wall clock)
static func streak(perDay: [String: Int], asOf now: Date) -> Int {
let cal = Calendar.current
func used(_ day: Date) -> Bool { (perDay[fmt.string(from: day)] ?? 0) > 0 }
var day = now
if !used(day) {
// Streak is still alive if used yesterday but not yet today.
day = cal.date(byAdding: .day, value: -1, to: day) ?? day
if !used(day) { return 0 }
}
var streak = 0
while used(day) {
streak += 1
day = cal.date(byAdding: .day, value: -1, to: day) ?? day
}
return streak
}
static func subscriptionAvoidedUSD(firstUseDay: String?, asOf now: Date, annualPrice: Double) -> Int {
guard let first = firstUseDay, let firstDate = fmt.date(from: first) else { return 0 }
let days = max(1, Calendar.current.dateComponents([.day], from: firstDate, to: now).day ?? 0)
return Int((Double(days) / 365.0 * annualPrice).rounded())
}
static func nextMilestone(corrections: Int) -> (target: Int, label: String, progress: Double) {
let targets = [100, 500, 1_000, 5_000, 10_000, 50_000, 100_000]
let target = targets.first { $0 > corrections } ?? (((corrections / 100_000) + 1) * 100_000)
let previous = targets.last { $0 <= corrections } ?? 0
let span = max(1, target - previous)
let progress = min(1.0, Double(corrections - previous) / Double(span))
let label = target >= 1_000 ? "\(target / 1_000)k corrections" : "\(target) corrections"
return (target, label, progress)
}
/// 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())
if data.perDay[k] == nil { // first action on a brand-new day
data.activeDays += 1
if data.firstUseDay == nil { data.firstUseDay = k }
}
data.perDay[k, default: 0] += 1
// Keep only ~60 days of history.
if data.perDay.count > 70 {
let keep = Set((0..<60).compactMap { off -> String? in
guard let day = Calendar.current.date(byAdding: .day, value: -off, to: Date()) else { return nil }
return Self.fmt.string(from: day)
})
data.perDay = data.perDay.filter { keep.contains($0.key) }
}
}
private func persist() {
if let raw = try? JSONEncoder().encode(data) {
d.set(raw, forKey: key)
}
}
}

View File

@ -0,0 +1,76 @@
import Foundation
import AppKit
import ApplicationServices
/// Safe Accessibility helpers shared across the System layer.
///
/// `AXUIElementCopyAttributeValue` returning `.success` only guarantees a
/// non-null `CFTypeRef` — NOT that it is an `AXUIElement`. A misbehaving AX
/// server (Electron/Java/custom) can return a different CF type, and a forced
/// `as!` cast then traps and crashes the whole app. These helpers cast safely.
enum AX {
/// Safely bridge a CFTypeRef to AXUIElement, returning nil on type mismatch.
static func element(_ ref: CFTypeRef?) -> AXUIElement? {
guard let ref, CFGetTypeID(ref) == AXUIElementGetTypeID() else { return nil }
return (ref as! AXUIElement) // safe: type id verified above
}
/// Copy an attribute as an `AXUIElement` (nil on failure/type mismatch).
static func copyElement(_ element: AXUIElement, _ attribute: String) -> AXUIElement? {
var ref: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success else {
return nil
}
return AX.element(ref)
}
/// Copy an attribute as a `String` (nil on failure/wrong type).
static func copyString(_ element: AXUIElement, _ attribute: String) -> String? {
var ref: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success else {
return nil
}
return ref as? String
}
/// Bound each cross-process AX message so a wedged target app can't hang us.
static func setMessagingTimeout(_ element: AXUIElement, _ seconds: Float) {
AXUIElementSetMessagingTimeout(element, seconds)
}
/// Whether an element is a secure text field (a password input). GrammarGem
/// must NEVER read or rewrite these — so a user who forgot to exclude a
/// password manager or login form can't have a decrypted password captured,
/// sent through the grammar/AI engines, and written back. This is enforced in
/// code rather than relying on the user's exclusion blocklist.
static func isSecureTextField(_ element: AXUIElement) -> Bool {
// AXSecureTextField is the subrole AppKit/most Electron inputs report for
// password fields. String-compared (the constant isn't always imported).
copyString(element, kAXSubroleAttribute) == "AXSecureTextField"
}
/// The currently-focused UI element across the system — robust to the
/// system-wide element failing.
///
/// On recent macOS, `AXUIElementCreateSystemWide()` returns
/// `kAXErrorCannotComplete` (-25204) for *every* query made by a background /
/// accessory process (which is exactly what a menu-bar app like GrammarGem is).
/// When that happens the system-wide focused-element read yields nil and the
/// app "sees" no text or text fields at all. Resolving the focused element
/// through the **frontmost application's** own AX element works reliably in
/// that context, so we prefer the system-wide read (one IPC hop, correct on
/// OSes where it works) and transparently fall back to the per-app read.
static func focusedElement() -> AXUIElement? {
let systemWide = AXUIElementCreateSystemWide()
setMessagingTimeout(systemWide, 0.5)
if let element = copyElement(systemWide, kAXFocusedUIElementAttribute as String) {
return element
}
guard let pid = NSWorkspace.shared.frontmostApplication?.processIdentifier else {
return nil
}
let axApp = AXUIElementCreateApplication(pid)
setMessagingTimeout(axApp, 0.5)
return copyElement(axApp, kAXFocusedUIElementAttribute as String)
}
}

View File

@ -1,7 +1,7 @@
import Foundation import Foundation
import AppKit import AppKit
/// The "page blocker": apps and domains where GrammaGem should stay completely /// The "page blocker": apps and domains where GrammarGem should stay completely
/// out of the way — password managers, banking sites, anything sensitive. /// out of the way — password managers, banking sites, anything sensitive.
/// When the frontmost context is excluded, live monitoring and the hotkeys are /// When the frontmost context is excluded, live monitoring and the hotkeys are
/// suppressed. /// suppressed.
@ -10,8 +10,8 @@ final class Exclusions: ObservableObject {
@Published private(set) var blockedApps: [String] // bundle identifiers @Published private(set) var blockedApps: [String] // bundle identifiers
@Published private(set) var blockedDomains: [String] // hostnames, e.g. "chase.com" @Published private(set) var blockedDomains: [String] // hostnames, e.g. "chase.com"
private let appsKey = "GrammaGem.blockedApps" private let appsKey = "GrammarGem.blockedApps"
private let domainsKey = "GrammaGem.blockedDomains" private let domainsKey = "GrammarGem.blockedDomains"
private let d = UserDefaults.standard private let d = UserDefaults.standard
/// Common sensitive apps offered as one-tap suggestions in the UI. /// Common sensitive apps offered as one-tap suggestions in the UI.
@ -31,7 +31,7 @@ final class Exclusions: ObservableObject {
/// a browser's URL can't be read). /// a browser's URL can't be read).
var hasDomainRules: Bool { !blockedDomains.isEmpty } var hasDomainRules: Bool { !blockedDomains.isEmpty }
/// Whether GrammaGem should be suppressed for this frontmost context. /// Whether GrammarGem should be suppressed for this frontmost context.
func isBlocked(bundleID: String?, domain: String?) -> Bool { func isBlocked(bundleID: String?, domain: String?) -> Bool {
if let bundleID, blockedApps.contains(bundleID) { return true } if let bundleID, blockedApps.contains(bundleID) { return true }
if let host = domain?.lowercased(), !host.isEmpty { if let host = domain?.lowercased(), !host.isEmpty {

View File

@ -19,18 +19,40 @@ struct DetectedField: Identifiable {
/// Performance & safety: the Accessibility-tree traversal (the part that can hang /// Performance & safety: the Accessibility-tree traversal (the part that can hang
/// on a busy/wedged app) runs OFF the main thread with a per-message timeout; only /// on a busy/wedged app) runs OFF the main thread with a per-message timeout; only
/// the bounded spell/grammar pass and publish happen on the main actor. It never /// the bounded spell/grammar pass and publish happen on the main actor. It never
/// scans GrammaGem itself, respects the page blocker, and applies fixes against the /// scans GrammarGem itself, respects the page blocker, and applies fixes against the
/// element's *live* value so it can't overwrite text typed since the last scan. /// element's *live* value so it can't overwrite text typed since the last scan.
@MainActor @MainActor
final class LiveMonitor: ObservableObject { final class LiveMonitor: ObservableObject {
@Published private(set) var fields: [DetectedField] = [] @Published private(set) var fields: [DetectedField] = []
@Published private(set) var scannedCount = 0 @Published private(set) var scannedCount = 0
@Published private(set) var scannedWordCount = 0
@Published private(set) var activeAppName = "" @Published private(set) var activeAppName = ""
@Published private(set) var running = false @Published private(set) var running = false
/// Total number of issues across all detected text areas. /// Total number of issues across all detected text areas.
var issueCount: Int { fields.reduce(0) { $0 + $1.suggestions.count } } var issueCount: Int { fields.reduce(0) { $0 + $1.suggestions.count } }
/// Outstanding issues grouped into the user-facing buckets (for filter chips).
var bucketCounts: [IssueBucket: Int] {
var counts: [IssueBucket: Int] = [:]
for field in fields {
for s in field.suggestions { counts[s.kind.bucket, default: 0] += 1 }
}
return counts
}
/// 0–100 on-device cleanliness score for the text currently on screen.
var writingScore: Int {
let weighted = fields.reduce(0) { acc, f in
acc + f.suggestions.reduce(0) { $0 + $1.kind.severity }
}
return WritingScore.score(weightedIssues: weighted, words: scannedWordCount)
}
/// Content keys of suggestions the user has dismissed; survives re-scans so a
/// dismissed issue doesn't reappear on the next poll.
private var dismissedKeys: Set<String> = []
private let grammar: GrammarEngine private let grammar: GrammarEngine
private let detector: AppDetector private let detector: AppDetector
private let exclusions: Exclusions private let exclusions: Exclusions
@ -88,6 +110,36 @@ final class LiveMonitor: ObservableObject {
refreshSoon() refreshSoon()
} }
// MARK: - Dismissing (hide a suggestion without applying it)
/// Hide one suggestion. A cheap, always-available "no" is what makes saying
/// "yes" (Apply) feel safe. The dismissal is keyed by content so it persists
/// across re-scans of unchanged text.
func dismiss(_ suggestion: Suggestion, in field: DetectedField) {
dismissedKeys.insert(Self.dismissKey(fieldID: field.id, suggestion))
fields = filterDismissed(fields)
}
private static func dismissKey(fieldID: String, _ s: Suggestion) -> String {
"\(fieldID)|\(s.location)|\(s.length)|\(s.original)|\(s.replacement)"
}
private func filterDismissed(_ input: [DetectedField]) -> [DetectedField] {
input.compactMap { f in
let remaining = f.suggestions.filter {
!dismissedKeys.contains(Self.dismissKey(fieldID: f.id, $0))
}
guard !remaining.isEmpty else { return nil }
return DetectedField(id: f.id, label: f.label, roleName: f.roleName,
text: f.text, suggestions: remaining,
element: f.element, isFocused: f.isFocused)
}
}
private static func wordCount(_ s: String) -> Int {
s.split { $0.isWhitespace || $0.isNewline }.count
}
/// Correct the element's *current* value (never a stale snapshot), so any /// Correct the element's *current* value (never a stale snapshot), so any
/// keystrokes entered since the scan are preserved. /// keystrokes entered since the scan are preserved.
private func applyCorrection(to field: DetectedField) -> Int { private func applyCorrection(to field: DetectedField) -> Int {
@ -127,7 +179,7 @@ final class LiveMonitor: ObservableObject {
if scanning && !force { return } // don't pile up scans if scanning && !force { return } // don't pile up scans
guard let app = NSWorkspace.shared.frontmostApplication else { clear(); return } guard let app = NSWorkspace.shared.frontmostApplication else { clear(); return }
// Never scan GrammaGem's own windows. // Never scan GrammarGem's own windows.
if app.processIdentifier == ownPID { clear(); return } if app.processIdentifier == ownPID { clear(); return }
activeAppName = app.localizedName ?? "" activeAppName = app.localizedName ?? ""
@ -156,6 +208,11 @@ final class LiveMonitor: ObservableObject {
guard generation == scanGeneration else { return } // superseded by a newer scan guard generation == scanGeneration else { return } // superseded by a newer scan
guard !scanned.isEmpty else { clear(); return } guard !scanned.isEmpty else { clear(); return }
// Total words across ALL scanned fields (clean ones included) so the
// writing score is honest about the whole on-screen text, not just the
// fields that happen to have issues.
scannedWordCount = scanned.reduce(0) { $0 + Self.wordCount($1.text) }
// Change detection includes focus + element identity so tabbing between // Change detection includes focus + element identity so tabbing between
// same-text fields still updates the focused indicator. // same-text fields still updates the focused indicator.
var hasher = Hasher() var hasher = Hasher()
@ -179,16 +236,17 @@ final class LiveMonitor: ObservableObject {
text: f.text, suggestions: suggestions, element: f.element, isFocused: f.isFocused)) text: f.text, suggestions: suggestions, element: f.element, isFocused: f.isFocused))
} }
detected.sort { ($0.isFocused ? 0 : 1) < ($1.isFocused ? 0 : 1) } detected.sort { ($0.isFocused ? 0 : 1) < ($1.isFocused ? 0 : 1) }
fields = detected fields = filterDismissed(detected)
} }
private nonisolated static func systemFocusedElement() -> AXUIElement? { private nonisolated static func systemFocusedElement() -> AXUIElement? {
AX.copyElement(AXUIElementCreateSystemWide(), kAXFocusedUIElementAttribute) AX.focusedElement()
} }
private func clear() { private func clear() {
if !fields.isEmpty { fields = [] } if !fields.isEmpty { fields = [] }
scannedCount = 0 scannedCount = 0
scannedWordCount = 0
lastHash = 0 lastHash = 0
} }
} }

View File

@ -0,0 +1,311 @@
import Foundation
import Network
/// A minimal, loopback-only HTTP server that exposes GrammarGem's local engines
/// (Harper grammar + the on-device LLM) to a **browser extension** — the
/// highest-volume surface for a grammar tool — with NO cloud round-trip. Built on
/// Network.framework so it adds no third-party HTTP dependency, keeping the
/// privacy/trust surface minimal.
///
/// Security posture (deliberate):
/// - Binds to 127.0.0.1 only (never a routable interface), via `requiredLocalEndpoint`.
/// - Every engine endpoint requires a per-install bearer token (`X-GrammarGem-Token`);
/// a web page that doesn't know the token cannot drive the engines.
/// - OFF by default — only starts when the user opts in, so the app ships no
/// listening socket unless explicitly enabled.
///
/// It reuses the SAME engines as the menu-bar app via injected async handlers, so
/// there is one engine, one set of prompts (`AIPrompts`), and one entitlement gate
/// across the app, the extension, and (later) other backends.
final class LocalEngineServer {
struct AIRequest { let action: AIAction; let text: String }
struct WireSuggestion: Encodable {
let location: Int
let length: Int
let original: String
let replacement: String
let kind: String
let message: String
}
/// Grammar pass: corrected text + suggestion spans for the extension to underline.
typealias GrammarHandler = (String) async -> (corrected: String, suggestions: [WireSuggestion])
/// AI action outcome (a plain enum so a user-facing message needn't be an `Error`).
enum AIOutcome { case ok(String); case error(String) }
/// AI action: runs the gate + model, returns the result or a user-facing error.
typealias AIHandler = (AIRequest) async -> AIOutcome
/// Streaming AI action: runs the gate + model, invoking `onChunk` per token as it
/// generates, then returns the final outcome (full text, or an error message).
typealias AIStreamHandler = (AIRequest, @escaping (String) -> Void) async -> AIOutcome
private let port: UInt16
private let token: String
private let grammarHandler: GrammarHandler
private let aiHandler: AIHandler
private let aiStreamHandler: AIStreamHandler
private var listener: NWListener?
private let queue = DispatchQueue(label: "com.grammargem.localserver")
init(port: UInt16, token: String,
grammarHandler: @escaping GrammarHandler,
aiHandler: @escaping AIHandler,
aiStreamHandler: @escaping AIStreamHandler) {
self.port = port
self.token = token
self.grammarHandler = grammarHandler
self.aiHandler = aiHandler
self.aiStreamHandler = aiStreamHandler
}
/// A stable per-install token, persisted so the extension can be paired once.
static func loadOrCreateToken() -> String {
let d = UserDefaults.standard
if let existing = d.string(forKey: AppConfig.LocalServer.tokenKey), !existing.isEmpty {
return existing
}
let token = UUID().uuidString + UUID().uuidString.replacingOccurrences(of: "-", with: "")
d.set(token, forKey: AppConfig.LocalServer.tokenKey)
return token
}
func start() {
guard listener == nil else { return }
do {
let params = NWParameters.tcp
guard let nwPort = NWEndpoint.Port(rawValue: port) else { return }
// Loopback-only: refuse to bind to any routable interface.
params.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: nwPort)
let listener = try NWListener(using: params)
listener.newConnectionHandler = { [weak self] conn in self?.accept(conn) }
listener.start(queue: queue)
self.listener = listener
Log.app.info("Local engine server on 127.0.0.1:\(self.port, privacy: .public) (extension bridge)")
} catch {
Log.app.error("Local engine server failed to start: \(error.localizedDescription, privacy: .public)")
}
}
func stop() {
listener?.cancel()
listener = nil
}
// MARK: - Connection lifecycle
private func accept(_ conn: NWConnection) {
conn.start(queue: queue)
receive(conn, buffer: Data())
}
private func receive(_ conn: NWConnection, buffer: Data) {
conn.receive(minimumIncompleteLength: 1, maximumLength: 1 << 16) { [weak self] data, _, isComplete, error in
guard let self else { conn.cancel(); return }
var buffer = buffer
if let data { buffer.append(data) }
if let request = HTTPRequest(buffer), request.isComplete {
Task { await self.route(request, on: conn) }
return
}
if isComplete || error != nil { conn.cancel(); return }
self.receive(conn, buffer: buffer) // headers/body not fully arrived yet
}
}
// MARK: - Routing
private func route(_ request: HTTPRequest, on conn: NWConnection) async {
// CORS preflight — extensions issue this before a cross-origin POST.
if request.method == "OPTIONS" {
send(conn, status: 204, body: Data())
return
}
if request.path == "/health" || request.path == "/v1/health" {
sendJSON(conn, status: 200, HealthResponse(ok: true, app: AppConfig.appName, version: AppConfig.appVersion))
return
}
// Token gate for every engine endpoint.
guard request.headers["x-grammargem-token"] == token else {
sendJSON(conn, status: 401, ErrorResponse(error: "unauthorized"))
return
}
switch (request.method, request.path) {
case ("POST", "/v1/grammar"):
guard let text = decodeText(request.body) else {
sendJSON(conn, status: 400, ErrorResponse(error: "missing 'text'")); return
}
let result = await grammarHandler(text)
sendJSON(conn, status: 200, GrammarResponse(corrected: result.corrected, suggestions: result.suggestions))
case ("POST", "/v1/ai"):
guard let req = decodeAI(request.body) else {
sendJSON(conn, status: 400, ErrorResponse(error: "bad request")); return
}
switch await aiHandler(req) {
case .ok(let out): sendJSON(conn, status: 200, AIResponse(result: out))
case .error(let msg): sendJSON(conn, status: 422, ErrorResponse(error: msg))
}
case ("POST", "/v1/ai/stream"):
guard let req = decodeAI(request.body) else {
sendJSON(conn, status: 400, ErrorResponse(error: "bad request")); return
}
// Newline-delimited JSON frames: {"chunk":"…"} per token, then a final
// {"done":true} or {"error":"…"}. The 200 header goes out before tokens,
// so an entitlement/readiness failure surfaces as a closing error frame.
beginStream(conn)
let outcome = await aiStreamHandler(req) { [weak self] chunk in
self?.streamFrame(conn, ["chunk": chunk])
}
switch outcome {
case .ok: streamFrame(conn, ["done": true])
case .error(let msg): streamFrame(conn, ["error": msg])
}
endStream(conn)
default:
sendJSON(conn, status: 404, ErrorResponse(error: "not found"))
}
}
// MARK: - Body decoding
private func decodeText(_ body: Data) -> String? {
struct P: Decodable { let text: String }
guard let p = try? JSONDecoder().decode(P.self, from: body), !p.text.isEmpty else { return nil }
return p.text
}
private func decodeAI(_ body: Data) -> AIRequest? {
struct P: Decodable {
let action: String
let text: String
let instruction: String?
let language: String?
let mode: String?
let tone: String?
}
guard let p = try? JSONDecoder().decode(P.self, from: body), !p.text.isEmpty else { return nil }
let action: AIAction
switch p.action {
case "rewriteClarity": action = .rewriteClarity
case "rewrite": action = .rewrite
case "ask":
guard let i = p.instruction, !i.trimmingCharacters(in: .whitespaces).isEmpty else { return nil }
action = .ask(i)
case "translate":
guard let l = p.language, !l.isEmpty else { return nil }
action = .translate(language: l)
case "tone":
action = .adjustTone(Tone(rawValue: p.tone ?? "") ?? .professional)
case "mode":
guard let id = p.mode, let m = ModeRegistry.mode(id: id) else { return nil }
action = .applyMode(m)
default:
return nil
}
return AIRequest(action: action, text: p.text)
}
// MARK: - Responses
private struct HealthResponse: Encodable { let ok: Bool; let app: String; let version: String }
private struct GrammarResponse: Encodable { let corrected: String; let suggestions: [WireSuggestion] }
private struct AIResponse: Encodable { let result: String }
private struct ErrorResponse: Encodable { let error: String }
private func sendJSON<T: Encodable>(_ conn: NWConnection, status: Int, _ value: T) {
let body = (try? JSONEncoder().encode(value)) ?? Data("{}".utf8)
send(conn, status: status, body: body)
}
// MARK: - Streaming responses (newline-delimited JSON, no Content-Length)
private func beginStream(_ conn: NWConnection) {
var head = "HTTP/1.1 200 OK\r\n"
head += "Content-Type: application/x-ndjson\r\n"
head += "Cache-Control: no-cache\r\n"
head += "Access-Control-Allow-Origin: *\r\n"
head += "Access-Control-Allow-Headers: Content-Type, X-GrammarGem-Token\r\n"
head += "Connection: close\r\n\r\n"
conn.send(content: Data(head.utf8), completion: .contentProcessed { _ in })
}
private func streamFrame(_ conn: NWConnection, _ object: [String: Any]) {
guard var data = try? JSONSerialization.data(withJSONObject: object) else { return }
data.append(0x0A) // newline delimiter
conn.send(content: data, completion: .contentProcessed { _ in })
}
private func endStream(_ conn: NWConnection) {
conn.send(content: nil, completion: .contentProcessed { _ in conn.cancel() })
}
private func send(_ conn: NWConnection, status: Int, body: Data) {
var head = "HTTP/1.1 \(status) \(Self.reason(status))\r\n"
head += "Content-Type: application/json\r\n"
head += "Content-Length: \(body.count)\r\n"
// The extension calls cross-origin; a secret token (not the origin) is the gate.
head += "Access-Control-Allow-Origin: *\r\n"
head += "Access-Control-Allow-Headers: Content-Type, X-GrammarGem-Token\r\n"
head += "Access-Control-Allow-Methods: POST, GET, OPTIONS\r\n"
head += "Connection: close\r\n\r\n"
var out = Data(head.utf8)
out.append(body)
conn.send(content: out, completion: .contentProcessed { _ in conn.cancel() })
}
private static func reason(_ status: Int) -> String {
switch status {
case 200: return "OK"
case 204: return "No Content"
case 400: return "Bad Request"
case 401: return "Unauthorized"
case 404: return "Not Found"
case 422: return "Unprocessable Entity"
default: return "Error"
}
}
}
/// A just-enough HTTP/1.1 request parser for small JSON POSTs. Returns nil until
/// the headers terminator (`\r\n\r\n`) has arrived; `isComplete` is false until the
/// full `Content-Length` body is buffered, so the receive loop knows to read more.
private struct HTTPRequest {
let method: String
let path: String
let headers: [String: String]
let body: Data
let isComplete: Bool
init?(_ raw: Data) {
guard let headerEnd = raw.range(of: Data("\r\n\r\n".utf8)) else { return nil }
guard let headerString = String(data: raw[..<headerEnd.lowerBound], encoding: .utf8) else { return nil }
var lines = headerString.components(separatedBy: "\r\n")
guard let requestLine = lines.first else { return nil }
let parts = requestLine.split(separator: " ")
guard parts.count >= 2 else { return nil }
method = String(parts[0])
path = String(parts[1])
lines.removeFirst()
var hdrs: [String: String] = [:]
for line in lines {
guard let colon = line.firstIndex(of: ":") else { continue }
let key = line[..<colon].trimmingCharacters(in: .whitespaces).lowercased()
let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
hdrs[key] = value
}
headers = hdrs
let contentLength = Int(hdrs["content-length"] ?? "0") ?? 0
let available = raw[headerEnd.upperBound...]
body = Data(available.prefix(contentLength))
isComplete = body.count >= contentLength
}
}

View File

@ -2,7 +2,7 @@ import Foundation
import AppKit import AppKit
import ApplicationServices import ApplicationServices
/// Tracks and requests the macOS permissions GrammaGem needs to read & replace /// Tracks and requests the macOS permissions GrammarGem needs to read & replace
/// text system-wide: **Accessibility** (required) and, for the synthetic-keystroke /// text system-wide: **Accessibility** (required) and, for the synthetic-keystroke
/// clipboard fallback, **Input Monitoring** may also be prompted by the system. /// clipboard fallback, **Input Monitoring** may also be prompted by the system.
@MainActor @MainActor

View File

@ -13,7 +13,7 @@ struct Snippet: Codable, Identifiable, Equatable {
final class SnippetStore: ObservableObject { final class SnippetStore: ObservableObject {
@Published private(set) var snippets: [Snippet] @Published private(set) var snippets: [Snippet]
private let key = "GrammaGem.snippets" private let key = "GrammarGem.snippets"
private let d = UserDefaults.standard private let d = UserDefaults.standard
init() { init() {

View File

@ -4,7 +4,7 @@ import ApplicationServices
import Carbon.HIToolbox import Carbon.HIToolbox
/// Reads the user's selected text from the frontmost app and writes a corrected /// Reads the user's selected text from the frontmost app and writes a corrected
/// value back — the mechanism that makes GrammaGem work *everywhere*. /// value back — the mechanism that makes GrammarGem work *everywhere*.
/// ///
/// Two paths, mirroring the spec (§4): /// Two paths, mirroring the spec (§4):
/// 1. **Accessibility API** (preferred): read/write `kAXSelectedTextAttribute` /// 1. **Accessibility API** (preferred): read/write `kAXSelectedTextAttribute`
@ -24,10 +24,12 @@ final class TextCapture {
enum CaptureError: LocalizedError { enum CaptureError: LocalizedError {
case noSelection case noSelection
case notPermitted case notPermitted
case secureField
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .noSelection: return "Select some text first, then press the hotkey." case .noSelection: return "Select some text first, then press the hotkey."
case .notPermitted: return "GrammaGem needs Accessibility permission to read and replace text." case .notPermitted: return "GrammarGem needs Accessibility permission to read and replace text."
case .secureField: return "GrammarGem never reads password fields."
} }
} }
} }
@ -41,8 +43,15 @@ final class TextCapture {
// MARK: - Capture // MARK: - Capture
/// Try AX first; on empty/unavailable, fall back to the clipboard path. /// Try AX first; on empty/unavailable, fall back to the clipboard path.
/// Refuses outright if the focused element is a password field.
func capture() throws -> Capture { func capture() throws -> Capture {
if let ax = captureViaAccessibility() { let focused = AXIsProcessTrusted() ? AX.focusedElement() : nil
if let focused, AX.isSecureTextField(focused) {
// Bail before the clipboard fallback too — never synthesize ⌘C into a
// password field.
throw CaptureError.secureField
}
if let ax = captureViaAccessibility(focused) {
return ax return ax
} }
if let clip = try captureViaClipboard() { if let clip = try captureViaClipboard() {
@ -51,11 +60,8 @@ final class TextCapture {
throw CaptureError.noSelection throw CaptureError.noSelection
} }
private func captureViaAccessibility() -> Capture? { private func captureViaAccessibility(_ focused: AXUIElement?) -> Capture? {
guard AXIsProcessTrusted() else { return nil } guard let focused else { return nil }
let systemWide = AXUIElementCreateSystemWide()
guard let focused = AX.copyElement(systemWide, kAXFocusedUIElementAttribute) else { return nil }
var valueRef: CFTypeRef? var valueRef: CFTypeRef?
guard AXUIElementCopyAttributeValue( guard AXUIElementCopyAttributeValue(
@ -93,8 +99,8 @@ final class TextCapture {
/// The full text value of the focused element (not just the selection). /// The full text value of the focused element (not just the selection).
func focusedFieldText() -> (text: String, element: AXUIElement)? { func focusedFieldText() -> (text: String, element: AXUIElement)? {
guard AXIsProcessTrusted() else { return nil } guard AXIsProcessTrusted() else { return nil }
let systemWide = AXUIElementCreateSystemWide() guard let focused = AX.focusedElement() else { return nil }
guard let focused = AX.copyElement(systemWide, kAXFocusedUIElementAttribute) else { return nil } guard !AX.isSecureTextField(focused) else { return nil } // never fix a password field
// Only treat genuine editable text roles as fixable, so we don't report // Only treat genuine editable text roles as fixable, so we don't report
// "nothing selected" failures on sliders/steppers/custom controls. // "nothing selected" failures on sliders/steppers/custom controls.

View File

@ -15,7 +15,7 @@ struct ScannedField {
/// Walks the Accessibility tree of the frontmost window and collects every /// Walks the Accessibility tree of the frontmost window and collects every
/// editable text element (text fields, text areas, combo/search boxes) with /// editable text element (text fields, text areas, combo/search boxes) with
/// content — so GrammaGem can check *all* the text on screen, not just the one /// content — so GrammarGem can check *all* the text on screen, not just the one
/// the cursor is in. Bounded so huge web/Electron trees can't stall the app, and /// the cursor is in. Bounded so huge web/Electron trees can't stall the app, and
/// every cross-process message has a timeout so a wedged app can't hang the scan. /// every cross-process message has a timeout so a wedged app can't hang the scan.
/// ///
@ -47,7 +47,9 @@ enum TextFieldScanner {
if role == kAXTextFieldRole as String if role == kAXTextFieldRole as String
|| role == kAXTextAreaRole as String || role == kAXTextAreaRole as String
|| role == kAXComboBoxRole as String { || role == kAXComboBoxRole as String {
if let value = AX.copyString(el, kAXValueAttribute), // Never surface password fields in the live monitor.
if !AX.isSecureTextField(el),
let value = AX.copyString(el, kAXValueAttribute),
value.trimmingCharacters(in: .whitespacesAndNewlines).count >= 3 { value.trimmingCharacters(in: .whitespacesAndNewlines).count >= 3 {
let label = AX.copyString(el, kAXTitleAttribute) let label = AX.copyString(el, kAXTitleAttribute)
?? AX.copyString(el, kAXDescriptionAttribute) ?? AX.copyString(el, kAXDescriptionAttribute)

View File

@ -12,14 +12,19 @@ final class TextReplacementCoordinator {
private let ai: AIEngine private let ai: AIEngine
private let gate: FeatureGate private let gate: FeatureGate
private let detector: AppDetector private let detector: AppDetector
/// Vocabulary the LLM must reproduce verbatim (personal dictionary / style
/// guide). Read fresh per action so dictionary edits take effect immediately.
private let protectedTerms: () -> [String]
init(capture: TextCapture, grammar: GrammarEngine, ai: AIEngine, init(capture: TextCapture, grammar: GrammarEngine, ai: AIEngine,
gate: FeatureGate, detector: AppDetector) { gate: FeatureGate, detector: AppDetector,
protectedTerms: @escaping () -> [String] = { [] }) {
self.capture = capture self.capture = capture
self.grammar = grammar self.grammar = grammar
self.ai = ai self.ai = ai
self.gate = gate self.gate = gate
self.detector = detector self.detector = detector
self.protectedTerms = protectedTerms
} }
/// The "fix" hotkey — instant grammar correction (Layer 1, always free). /// The "fix" hotkey — instant grammar correction (Layer 1, always free).
@ -52,7 +57,7 @@ final class TextReplacementCoordinator {
do { cap = try await captureOffMain() } catch { return .failed(error.localizedDescription) } do { cap = try await captureOffMain() } catch { return .failed(error.localizedDescription) }
do { do {
let result = try await ai.run(action, on: cap.text) let result = try await ai.run(action, on: cap.text, protectedTerms: protectedTerms())
gate.recordAIActionUsed() gate.recordAIActionUsed()
let ok = await replaceOffMain(result, cap) let ok = await replaceOffMain(result, cap)
return ok ? .replaced(result) : .failed("Couldn't write the result back.") return ok ? .replaced(result) : .failed("Couldn't write the result back.")
@ -73,7 +78,7 @@ final class TextReplacementCoordinator {
do { cap = try await captureOffMain() } catch { return .failed(error.localizedDescription) } do { cap = try await captureOffMain() } catch { return .failed(error.localizedDescription) }
do { do {
let result = try await ai.run(action, on: cap.text) let result = try await ai.run(action, on: cap.text, protectedTerms: protectedTerms())
gate.recordAIActionUsed() gate.recordAIActionUsed()
let ok = await replaceOffMain(result, cap) let ok = await replaceOffMain(result, cap)
return ok ? .replaced(result) : .failed("Couldn't write the result back.") return ok ? .replaced(result) : .failed("Couldn't write the result back.")

View File

@ -0,0 +1,81 @@
import Foundation
import Sparkle
/// Wraps Sparkle's updater. It background-checks the appcast on grammargem.com and
/// publishes `updateAvailableVersion` when a newer, EdDSA-verified build exists, so
/// the menu bar can show an "Update available" button. Triggering an update runs
/// Sparkle's verified download + install flow.
///
/// The updater is only started inside the packaged `.app` (where the Info.plist
/// carries `SUFeedURL` + `SUPublicEDKey`); under `swift run`, tests, or the
/// self-test binaries it stays inert so nothing crashes.
@MainActor
final class UpdaterManager: NSObject, ObservableObject {
@Published private(set) var updateAvailableVersion: String?
private var controller: SPUStandardUpdaterController?
override init() {
super.init()
guard Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") != nil else {
return // not the packaged app — no updater
}
controller = SPUStandardUpdaterController(
startingUpdater: true, updaterDelegate: self, userDriverDelegate: nil)
}
var canCheckForUpdates: Bool { controller?.updater.canCheckForUpdates ?? false }
/// User-initiated check — shows Sparkle's UI (used by the menu item and the
/// "Update" button on the availability banner).
func checkForUpdates() {
controller?.updater.checkForUpdates()
}
}
extension UpdaterManager: SPUUpdaterDelegate {
func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
updateAvailableVersion = item.displayVersionString
}
func updaterDidNotFindUpdate(_ updater: SPUUpdater) {
updateAvailableVersion = nil
}
}
// MARK: - Headless update self-test (build pipeline / CI)
/// Checks `feedURL` for a newer version with no UI and prints the result, then
/// exits. Used to verify the appcast + signature + version-comparison path.
enum UpdaterSelfTest {
static func run(feedURL: String) {
let delegate = UpdateProbeDelegate(feed: feedURL)
UpdateProbeDelegate.retained = delegate
UpdateProbeDelegate.controller = SPUStandardUpdaterController(
startingUpdater: true, updaterDelegate: delegate, userDriverDelegate: nil)
UpdateProbeDelegate.controller?.updater.checkForUpdateInformation()
RunLoop.main.run() // Sparkle's network check needs the run loop; delegate exits.
}
}
private final class UpdateProbeDelegate: NSObject, SPUUpdaterDelegate {
static var controller: SPUStandardUpdaterController?
static var retained: UpdateProbeDelegate?
let feed: String
init(feed: String) { self.feed = feed; super.init() }
func feedURLString(for updater: SPUUpdater) -> String? { feed.isEmpty ? nil : feed }
func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
print("UPDATE_FOUND: \(item.displayVersionString)")
exit(0)
}
func updaterDidNotFindUpdate(_ updater: SPUUpdater) {
print("UPDATE_NONE")
exit(0)
}
func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
print("UPDATE_ERROR: \(error.localizedDescription)")
exit(1)
}
}

View File

@ -1,78 +0,0 @@
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)
}
}

View File

@ -0,0 +1,222 @@
import XCTest
@testable import GrammarGem
final class GrammarGemTests: 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" })
}
/// Real Harper does true grammar NSSpellChecker can't: subject-verb
/// agreement and verb-form correction, applied cleanly in place.
func testHarperRealGrammarCorrections() {
let harper = HarperEngine()
let fixed = harper.correct("She dont like it and she have went home.")
XCTAssertTrue(fixed.contains("don't"), "dont -> don't")
XCTAssertTrue(fixed.contains("has"), "have -> has (subject-verb agreement)")
XCTAssertTrue(fixed.contains("gone"), "went -> gone (past participle)")
XCTAssertFalse(fixed.contains("went"), "the wrong verb form should be gone")
}
/// The trust bug: a mid-sentence spelling fix must keep the original casing.
func testSpellingReplacementPreservesCase() {
XCTAssertEqual(SystemGrammarEngine.matchingCase(of: "problemm", in: "Problem"), "problem")
XCTAssertEqual(SystemGrammarEngine.matchingCase(of: "Teh", in: "the"), "The")
XCTAssertEqual(SystemGrammarEngine.matchingCase(of: "teh", in: "the"), "the")
XCTAssertEqual(SystemGrammarEngine.matchingCase(of: "HELLOO", in: "hello"), "HELLO")
}
/// Harper returns char (Unicode scalar) offsets; they must map exactly onto
/// UTF-16 NSRange offsets even past multi-UTF-16-unit scalars (emoji).
func testHarperCharOffsetsMapToUTF16() {
let text = "😀 teh" // 😀 = 1 scalar / 2 UTF-16 units
let range = HarperEngine.utf16Range(scalarStart: 2, scalarLen: 3, in: text)
XCTAssertEqual(range, NSRange(location: 3, length: 3))
XCTAssertEqual((text as NSString).substring(with: range!), "teh")
}
// MARK: - Value loop (streak / subscription-avoided / milestones)
@MainActor
func testValueLoopMetrics() {
let fmt = DateFormatter()
fmt.dateFormat = "yyyy-MM-dd"
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.timeZone = .current
let cal = Calendar.current
let now = Date()
func key(_ daysAgo: Int) -> String {
fmt.string(from: cal.date(byAdding: .day, value: -daysAgo, to: now)!)
}
// today + yesterday + 2-days-ago = streak of 3 (the day-4 entry is broken off).
let perDay = [key(0): 2, key(1): 1, key(2): 5, key(4): 1]
XCTAssertEqual(UsageStats.streak(perDay: perDay, asOf: now), 3)
XCTAssertEqual(UsageStats.streak(perDay: [:], asOf: now), 0)
// Used yesterday but not yet today — streak is still alive.
XCTAssertEqual(UsageStats.streak(perDay: [key(1): 1], asOf: now), 1)
// ~1 year of ownership ≈ one year of the subscription you didn't pay.
XCTAssertEqual(
UsageStats.subscriptionAvoidedUSD(firstUseDay: key(365), asOf: now, annualPrice: 144), 144)
XCTAssertEqual(
UsageStats.subscriptionAvoidedUSD(firstUseDay: nil, asOf: now, annualPrice: 144), 0)
let m = UsageStats.nextMilestone(corrections: 50)
XCTAssertEqual(m.target, 100)
XCTAssertEqual(m.progress, 0.5, accuracy: 0.001)
XCTAssertEqual(UsageStats.nextMilestone(corrections: 100).target, 500)
}
// MARK: - MLX on-device LLM (integration; skipped unless the model is present)
/// Loads the real downloaded model and runs one generation. Skipped in CI /
/// on machines without the weights. Run with the model present to verify the
/// MLX integration end-to-end.
func testMLXRealGenerationIfModelPresent() async throws {
// Opt-in: `swift test` (CLI) can't place mlx-swift's metallib, so this is
// gated. Run it via the app's `--mlx-selftest`, or set GRAMMARGEM_MLX_TEST=1
// with the metallib colocated. See scripts/build-metallib.sh.
guard ProcessInfo.processInfo.environment["GRAMMARGEM_MLX_TEST"] == "1" else {
throw XCTSkip("set GRAMMARGEM_MLX_TEST=1 (metallib colocated) to run the MLX integration test")
}
guard let dir = ModelManager.completedModelDirectory(repo: AppConfig.Model.defaultRepo) else {
throw XCTSkip("on-device model not downloaded; skipping MLX integration test")
}
let engine = MLXEngine(modelDirectoryProvider: { dir })
XCTAssertTrue(engine.isReady)
let out = try await engine.run(.rewriteClarity,
on: "me and him was going to the store for to buy some milks")
print("MLX rewriteClarity -> \(out)")
XCTAssertFalse(out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
// 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: - AIPrompts (backend-neutral seam)
func testProtectedTermsInjectedIntoSystemPrompt() {
let base = AIPrompts.systemPrompt(for: .rewriteClarity)
let withTerms = AIPrompts.systemPrompt(for: .rewriteClarity, protectedTerms: ["GrammarGem", "Harper"])
XCTAssertFalse(base.contains("GrammarGem"))
XCTAssertTrue(withTerms.contains("\"GrammarGem\""))
XCTAssertTrue(withTerms.contains("\"Harper\""))
XCTAssertTrue(withTerms.contains("verbatim"))
}
func testProtectedTermsClauseSkipsWhenEmpty() {
let blank = AIPrompts.systemPrompt(for: .rewrite, protectedTerms: ["", " "])
XCTAssertEqual(blank, AIPrompts.systemPrompt(for: .rewrite))
}
func testMaxTokensHonorsModeLengthCap() {
// Default actions get the full budget…
XCTAssertEqual(AIPrompts.maxTokens(for: .rewriteClarity), 512)
// …but a short-form Mode (Team Chat, lengthCap 60) gets far fewer.
let slack = ModeRegistry.slack
XCTAssertNotNil(slack.lengthCap)
let capped = AIPrompts.maxTokens(for: .applyMode(slack))
XCTAssertLessThan(capped, 512)
XCTAssertGreaterThanOrEqual(capped, 64)
// A mode with no cap (Polish) falls back to the default budget.
XCTAssertEqual(AIPrompts.maxTokens(for: .applyMode(ModeRegistry.polish)), 512)
}
func testCleanStripsPreambleAndQuotes() {
XCTAssertEqual(AIPrompts.clean("Sure, here's the rewrite: Hello world"), "Hello world")
XCTAssertEqual(AIPrompts.clean("\"Quoted result\""), "Quoted result")
XCTAssertEqual(AIPrompts.clean(" already clean "), "already clean")
}
func testParseToneIsRobust() {
XCTAssertEqual(AIPrompts.parseTone("professional"), .professional)
XCTAssertEqual(AIPrompts.parseTone(" Friendly.\n"), .friendly)
XCTAssertEqual(AIPrompts.parseTone("the tone is academic here"), .academic)
XCTAssertEqual(AIPrompts.parseTone("???"), .professional) // safe fallback
}
// MARK: - Custom modes
@MainActor
func testCustomModeRejectsEmptyPromptAndDuplicates() {
// Deterministic regardless of prior runs (the store persists to UserDefaults).
UserDefaults.standard.removeObject(forKey: "GrammarGem.customModes")
defer { UserDefaults.standard.removeObject(forKey: "GrammarGem.customModes") }
let store = CustomModesStore()
XCTAssertFalse(store.addCustom(name: "Brand Voice", prompt: " "),
"empty prompt must be rejected")
XCTAssertTrue(store.addCustom(name: "Brand Voice", prompt: "Write in Acme's warm voice."))
XCTAssertFalse(store.addCustom(name: "brand voice", prompt: "different prompt"),
"duplicate name (case-insensitive) must be rejected")
XCTAssertFalse(store.addCustom(name: "Polish", prompt: "collides with a built-in"),
"name colliding with a built-in mode must be rejected")
}
// 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)
}
}

View File

@ -0,0 +1,204 @@
# Grammarly Value Teardown -> GrammarGem Plan
*Senior product + Swift engineering review. Every claim below is grounded in the current GrammarGem source. File references use absolute paths; the load-bearing bug is the mis-casing in `SystemGrammarEngine.swift:42-45`.*
---
## 1. Executive summary — what makes Grammarly FEEL valuable (priority order)
Grammarly's perceived value is NOT its grammar engine. It is a stack of interaction and psychology layers on top of a competent engine. Ranked by leverage for a one-time-purchase, on-device Mac utility:
1. **Trustworthy correction quality (the foundation).** Every felt-value mechanic above it collapses if a fix is wrong. Grammarly's "accept-accept-accept" rhythm only works because in-place application is reliable and casing/context is preserved. GrammarGem's engine currently *demonstrably violates this* (`problemm` -> `Problem` mid-sentence). Fix this first; nothing else matters until it's solid.
2. **The fix-as-button atomic loop: read the fix and apply it in one motion.** Grammarly's clickable green replacement chip collapses diagnosis + remedy + action into a single low-friction act, with a cheap, always-present dismiss so saying no is as easy as saying yes. GrammarGem has an `Apply` button but no dismiss/ignore/add-to-dictionary, no keyboard accept, and the apply path is brittle (whole-field AX overwrite).
3. **Categorization + counts + a score: turning a wall of red into a finite, beatable list.** Four named, color-coded buckets, per-category count chips, one running issue badge, and a 0-100 score convert dread into a checklist with a meter that only goes up. GrammarGem has a raw issue count (3 colors) and *zero* score concept.
4. **The rigged first-run "wow": guaranteed visible win in under 60 seconds.** A pre-seeded, error-salted demo doc means the product proves itself before asking for anything. GrammarGem's onboarding only handles Accessibility + model download — it never demonstrates the correction loop at all.
5. **Ongoing perceived value without surveillance: progress you can see.** Grammarly uses cloud percentiles + weekly behavioral emails. GrammarGem can recreate the *feeling* — self-vs-self, fully on-device — with a local score trend, streaks, milestones, and a "subscription you didn't pay" money figure. The plumbing (`UsageStats.perDay`) already exists.
6. **The generative "writing partner" layer: hand me the better sentence, not just the flag.** Sentence rewrites, tone detection/adjustment, conciseness. This is real value but is currently 100% simulated (`MLXEngine.swift` is a string-hack stub) and gated by a daily counter. It is the right *paid* tier, but it must produce real output before it can be sold.
The strategic asymmetry to exploit throughout: every Grammarly value moment carries an implicit "and you'll pay again next year." GrammarGem's identical mechanics should be framed "yours once, forever, on this Mac."
---
## 2. Mechanic-by-mechanic: how Grammarly does it -> how GrammarGem should do it
### Mechanic 1 — Trustworthy correction quality (see §3 for the full engineering plan)
- **Grammarly:** ranks by confidence, suppresses low-confidence edits, never garbles surrounding text, preserves casing, and limits bulk-apply to high-confidence objective mechanics only.
- **GrammarGem today:** `SystemGrammarEngine` blindly trusts `NSSpellChecker.correction(forWordRange:)`, which is sentence-position-aware and capitalizes a word it thinks starts a sentence — producing `problemm` -> `Problem` mid-sentence (`SystemGrammarEngine.swift:42-45`). `Suggestion` has no confidence/severity/alternatives (`Models.swift:48-59`). Default `correct()` applies *every* suggestion including no-op grammar flags where `replacement == original` (`GrammarEngine.swift:22-29`).
- **Do:** real Harper FFI; case/whitespace post-processing; add `confidence`/`severity`/`alternatives` to `Suggestion`; never auto-apply no-ops or low-confidence flags.
- **Files:** `SystemGrammarEngine.swift`, `HarperEngine.swift`, `GrammarEngine.swift`, `Models.swift`, `harper-ffi/`.
- **Impact: high · Effort: L**
### Mechanic 2 — The fix-as-button atomic loop (+ dismiss / ignore / add-to-dictionary)
- **Grammarly:** the suggested word *is* the button (green = go/safe/done); an always-present cheap dismiss (trash/Esc) and add-to-dictionary make accepting feel safe; Tab-to-navigate / Enter-to-accept / Esc-to-dismiss removes the mouse round-trip.
- **GrammarGem today:** `FieldCard` shows a message + one `original -> replacement` + a small `Apply` button (`LiveCheckView.swift:104-119`). There is **no dismiss, no ignore, no add-to-dictionary, no keyboard accept.** Per-issue `apply()` bails silently if the field changed at all since the snapshot (`LiveMonitor.swift:82-89`), and even single-issue apply rewrites the *whole* AX value (`LiveMonitor.swift:101-103`), risking caret/undo/formatting loss.
- **Do (realistic for a cross-app AX tool — we cannot draw a green chip inside arbitrary apps):**
- Make the replacement text itself the primary tappable control in the card (the chip *is* the fix), styled by category color.
- Add a per-suggestion overflow: **Dismiss** (model a `dismissedIDs: Set<UUID>` on the monitor so it survives re-scans), **Ignore word** / **Add to dictionary** (route into `PersonalDictionary` -> `ignoreList`), gated by `freeDictionaryCap`.
- Add **keyboard flow inside the Live Check panel**: ↑/↓ or Tab to move selection, Return to apply, Delete/Esc to dismiss. This is fully feasible because the panel is our own SwiftUI window.
- Replace whole-field AX write with **targeted span insertion** where the element supports `kAXSelectedTextRangeAttribute` + `kAXSelectedTextAttribute` (set selection to the issue range, then set selected text to the replacement) — preserves caret and undo. Fall back to whole-value only when selection-set is unsupported.
- Make per-issue apply **rebase** instead of bail: if the live value changed, re-locate the span by matching `original` near the old offset rather than refusing to act.
- **Files:** `LiveCheckView.swift`, `LiveMonitor.swift`, `Models.swift`, `PersonalDictionary.swift`, `AppState.swift`, `TextCapture`.
- **Impact: high · Effort: M**
### Mechanic 3 — Categorization, counts, and an on-device document score
- **Grammarly:** four named buckets (Correctness/Clarity/Engagement/Delivery) each with a color and a count chip that doubles as a filter; one master running badge; a 0-100 percentile score that ticks up on every accept; cards ordered by severity (objective errors first) so the early wins build trust.
- **GrammarGem today:** `GrammarKind` has 6 cases but `.punctuation` is dead; the panel reduces everything to 3 dot colors (`LiveCheckView.swift:136-142`); `issueCount` is a raw sum (`LiveMonitor.swift:32`); there is **no score, no per-category chips, no severity ordering, no filter**.
- **Do (on-device, no cloud, no percentile-vs-others):**
- Introduce GrammarGem's own named buckets that fit the engine: e.g. **Correctness** (spelling/grammar/punctuation), **Clarity** (phrasing/conciseness — from the real LLM), **Polish** (repetition/capitalization/style). Use original color semantics (do NOT clone Grammarly's exact red/blue/green/purple mapping or wording).
- Add **count chips per bucket** in the panel header that filter the card list; keep the menu-bar badge as the master tally (`MenuBarLabel`).
- Compute a **local cleanliness score** at check-time: a deterministic function of issues-per-100-words by severity (e.g. `100 - weighted_issues`). It is honest because it's defined relative to *this document*, not a hidden user corpus. Show it on the card header and trend it on the dashboard.
- **Order cards by severity then document position** (objective spelling/grammar first) so the first accept is a high-confidence win.
- Critical: the score must reflect the *honest* free promise — grammar/spelling is unlimited and fully resolvable, so a free user CAN reach a clean score. Do **not** manufacture a permanently-nonzero count to force upgrades (that's Grammarly's locked-bucket trick and it contradicts our positioning — see §5).
- **Files:** `Models.swift` (`GrammarKind` cleanup, add `severity`), `LiveMonitor.swift`, `LiveCheckView.swift`, `MenuBarContent.swift`, `DashboardView.swift`, a new `WritingScore` helper.
- **Impact: high · Effort: M**
### Mechanic 4 — The first-run "wow" (see §4 for the full sequence)
- **Grammarly:** land in an error-salted demo doc -> "we found N issues" -> one-click fixes vanish underlines + decrement counter -> goal setup -> install-everywhere prompt at the dopamine peak.
- **GrammarGem today:** `OnboardingView` only covers Accessibility + model download (`OnboardingView.swift`); it never shows the correction loop.
- **Do:** add an in-app demo step with a pre-seeded sample, a "we found N things to fix" reveal, and a satisfying one-click-to-clean animation — *before* asking for nothing-but-praise (no install-everywhere pressure; we're already system-wide). Detail in §4.
- **Files:** `OnboardingView.swift`, plus a small `DemoDocument` view reusing the highlight + apply UI.
- **Impact: high · Effort: M**
### Mechanic 5 — Ongoing perceived value (privacy-native, self-vs-self)
- **Grammarly:** 0-100 score, weekly Insights email ("more productive than X% of users"), lifetime words-checked counter, streaks — all cloud + behavioral-email, the exact surveillance we market against.
- **GrammarGem today:** `UsageStats` tracks `totalCorrections`, `totalAIActions`, `totalWords`, and `perDay` (`UsageStats.swift`); the dashboard shows four tiles + a 7-bar chart, but **no score, no streak, no week-over-week, no milestone, no money-saved figure**. `estimatedMinutesSaved` is a crude untuned constant (`8s/correction + 45s/AI-action`). Stats are invisible outside the main window.
- **Do (all local, no telemetry, stops nagging the moment they own it):**
- **Local writing-score trend** on the dashboard (from Mechanic 3), compared only to the user's own past.
- **Streak + milestones** from existing `perDay` data ("7-day streak", "1,000th correction", "100k words polished") — cheap, high word-of-mouth, and the right re-engagement hook for a no-renewal product.
- **"Subscription you didn't pay" figure** — translate usage into money: the marketing site already hammers "~\$144/yr vs pay once," but the *app* never does. Show "You've polished N times — that's the price of GrammarGem recovered M times over," computed locally. This is the strongest one-time-purchase value teaser and it lives where the user actually is.
- **Week-over-week delta** on the bar chart and a lightweight in-app weekly recap card (NOT an email; no pipeline, no surveillance).
- **Surface one stat in the menu bar dropdown** (the most-seen surface) so value reminders don't require opening the dashboard.
- Tune `estimatedMinutesSaved` to scale by words changed / action type so the headline "was it worth it?" number is credible.
- **Files:** `UsageStats.swift`, `DashboardView.swift`, `MenuBarContent.swift`, `web/src/lib/content.ts` (replace PLACEHOLDER stats/testimonials before launch).
- **Impact: high · Effort: M**
### Mechanic 6 — The generative "writing partner" layer (real LLM, honest gating)
- **Grammarly:** sentence rewrites ("Replace with This Version" + Highlight-Changes diff), tone detection (emoji + label), tone adjustment, conciseness, GrammarlyGO — all one-click, reversible, before/after.
- **GrammarGem today:** `MLXEngine` is a stub doing `replacingOccurrences` string hacks (`MLXEngine.swift`); `tidy()` even re-introduces casing risk; `detectTone` keys off substrings. `ModelManager` downloads real weights but nothing loads them. Gated by `freeDailyAIActions = 10` shared across rewrite/tone/Ask/translate.
- **Do:**
- Wire the **real MLX model** from `ModelManager.modelsDirectory` (the download is currently a dead-end); uncomment `mlx-swift` in `Package.swift`.
- Adopt the **one-click + reversible + before/after diff** delivery contract for every rewrite — this is what neutralizes "the AI is taking over my voice."
- Keep the **honest quantity gate**: free users get *real, full-quality* output, just rationed (the daily counter), plus Polish mode only. This is the parity-respecting tease — satisfaction-driven ("I keep hitting the limit / I want my own modes / I want it on my other Mac"), not anxiety-driven.
- Real **tone detection** (label + emoji) is the highest-value non-grammar feature; it delivers value on grammatically perfect text. Build it once the model is live.
- **Files:** `MLXEngine.swift`, `AIEngine.swift`, `ModelManager.swift`, `Package.swift`, `FeatureGate.swift`, `TextReplacementCoordinator.swift`, `WritingModesView.swift`.
- **Impact: high · Effort: L** (model integration is the long pole)
---
## 3. Correction-QUALITY: the path to trustworthy fixes (this underpins ALL perceived value)
The engine is the load-bearing wall. Today it is `NSSpellChecker` + a ~6-rule regex stub, and it ships a trust-destroying casing bug. Here is the ordered path.
### 3.1 Fix the casing/whitespace bug NOW (cheap, immediate)
Root cause: `SystemGrammarEngine.swift:42-45` blindly uses `checker.correction(forWordRange:)`, which is sentence/position-aware and returns a capitalized guess for a token it believes starts a sentence — so a mid-sentence `problemm` comes back `Problem`.
**Post-process every spelling replacement to preserve the original token's casing** unless the token is a true sentence start or proper noun:
- If `original` is all-lowercase, lowercase the replacement's first letter.
- If `original` is Capitalized (single leading cap), match that.
- If `original` is ALL-CAPS, upper-case the replacement.
- Only adopt the corrector's case when the token is at a genuine sentence boundary (preceded by `.?!` + space, or string start). Also trim/normalize whitespace so a replacement never injects a stray leading/trailing space.
This is a ~20-line transcase helper applied at suggestion-build time. It removes the single most visible trust failure before any deeper work.
### 3.2 Stop auto-applying garbage
- `GrammarEngine.correct()` (`GrammarEngine.swift:22-29`) applies **every** suggestion including grammar flags where `replacement == original` (the no-op flags emitted at `SystemGrammarEngine.swift:51-54` and `:67-70`). Filter these out of the bulk path entirely — a no-op flag must never be part of "Fix all," and a low-confidence guess must never auto-apply.
- "Fix all" should be **trust-scoped like Grammarly's bulk accept**: only high-confidence objective mechanics (spelling/grammar/punctuation), never subjective rewrites.
### 3.3 Enrich the data model
`Suggestion` (`Models.swift:48-59`) needs:
- `confidence: Double` (gate auto-apply below a threshold; show low-confidence ones as optional, never bulk-applied).
- `severity` (drives ordering and the score weighting).
- `alternatives: [String]` — `NSSpellChecker.guesses()` already returns multiple candidates but only `.first` is kept (`SystemGrammarEngine.swift:41`); surface the rest so the card can offer "other suggestions."
- `ruleID`/`source` — so feedback (accept/dismiss) can later tune ranking and so we can attribute a fix to Harper vs NSSpellChecker.
### 3.4 Integrate the real Harper core (the big precision unlock)
`harper-ffi/` is an empty README; `Package.swift` comments out all real deps. Stand up the Rust crate behind a C-FFI static lib (`harper_check(text) -> spans`), map spans into `Suggestion`, and keep the protocol boundary so the rest of the app is unchanged. This brings real subject-verb agreement, article/preposition usage, comma/apostrophe rules, commonly-confused pairs, etc. — the category coverage NSSpellChecker structurally cannot provide. De-dup against NSSpellChecker as today (`overlaps`), but prefer Harper where both fire.
### 3.5 Populate the dead `.punctuation` category
`.punctuation` is declared but never emitted, yet punctuation is one of the highest-volume real-world error classes. Harper covers most of it; this fills the gap and makes the new category chips (Mechanic 3) meaningful.
### 3.6 Incremental, async, grapheme-safe checking
- Today: whole-document, synchronous, `DispatchQueue.main.sync` on a 1.6s poll, truncated at 4000 chars (`LiveMonitor.swift:46,174`), frontmost window only. Move to **debounced incremental re-check of changed spans** with caching of unchanged ranges; keep the heavy AX traversal off-main (already done) but get the spell/grammar pass off the main thread too once Harper is FFI-based (no `NSSpellChecker` main-thread requirement).
- Validate that replacement sub-ranges fall on **grapheme boundaries** (emoji/combining marks) before applying — only a coarse bounds check exists today (`SystemGrammarEngine.swift:63`).
### 3.7 Feedback loop
Wire dismiss / add-to-dictionary (Mechanic 2) back into ranking so repeated false positives stop nagging — this is the "it learns" trust signal. Today `ignoreList` only suppresses; long-term, dismiss counts per `ruleID` can down-rank a noisy rule.
**Sequencing:** §3.1 + §3.2 this week (trust-critical, tiny). §3.3 alongside Mechanic 2. §3.4–§3.6 are the real Harper milestone. §3.7 last.
---
## 4. The first-run "wow" sequence for GrammarGem (original copy)
Goal: a guaranteed visible win in under 60 seconds, *inside our own window* (we don't need Grammarly's blank-canvas demo trick or its install-everywhere upsell — we're already system-wide). Onboarding today (`OnboardingView.swift`) skips this entirely.
Proposed flow:
1. **Welcome (1 line):** "A private writing assistant that lives in your menu bar and fixes text in every app — all on this Mac." (existing copy is close; keep it.)
2. **The rigged win — a live mini-demo BEFORE any permission ask.** Show a short pre-written paragraph salted with obvious, safe errors (a misspelling, a repeated word, a lowercase "i", a missing period). Render it with the highlight UI already built in `LiveCheckView.highlighted(...)`. Above it: **"We spotted N things to tidy up."** (count = guaranteed, because the sample is fixed.)
3. **One satisfying click.** A single "Clean it up" button animates the highlights resolving one by one; the count ticks to 0; the paragraph visibly improves. This is the operant loop — trivial action, immediate visible reward — experienced once, on rails, with zero risk. (Reuse the apply path; it's a local string, not a live AX element, so it's completely safe.)
4. **The reveal:** "That's GrammarGem. It does this in Mail, Slack, Notes, your browser — everywhere you type." Then the *first* ask: **Accessibility** (the existing step 2 / `stepCard`), now framed as "Turn it on everywhere."
5. **Optional model step:** keep the existing "Download the on-device model (optional) — grammar works instantly with no download" card. Frame the AI features as a bonus, not a blocker.
6. **No install-everywhere nag, no timed discount, no streak guilt.** End on a calm "You're all set" with one honest line about ownership: "Yours once. No subscription, nothing leaves your Mac."
Guardrails: the demo must use *original* sample text (not Grammarly's), and the count/score must be real (computed by our engine on the sample), never theatrical.
---
## 5. Do NOT copy (dark patterns + claims we can't truthfully make)
These are Grammarly mechanics that either depend on infrastructure we reject or that would corrode the trust/privacy moat that *is* the product.
1. **The premium-issue anxiety counter ("14 advanced issues you can't fix").** It works by detecting problems the engine deliberately won't fix. It clashes head-on with our promise that grammar/spelling/punctuation is unlimited and fully resolvable for free. Do not manufacture a permanently-nonzero count or a "score capped until you upgrade" gate.
2. **Two-tier gold-vs-red underlines (a second class of locked grammar issues).** Our underlines are all free and all resolvable; there is no second class to color differently. Inventing one betrays the core differentiator.
3. **Blur/lock a result we already computed.** On-device + open-source, hiding output we generated is both trivially bypassable and brand-corrosive. Gate by **quantity/frequency** (the daily counter) and **breadth** (modes, multi-device, snippets, larger model) — never by hiding the quality of a single result.
4. **Cloud percentile comparisons ("more productive than X% of users").** Requires an account + aggregate corpus + telemetry — the exact surveillance we market against. Replace with **self-vs-self** local trends.
5. **Behavioral weekly emails / re-nag pipeline.** For a one-time purchase there is no subscription to keep warm; post-purchase nagging is pure annoyance. Any weekly recap must be **in-app, local, opt-in, and stop entirely once they own it.**
6. **Countdown/limited-time discount urgency** (Grammarly's Day-7 40% timer). Manufactured scarcity undercuts the honest "buy once, fair price" positioning.
7. **Lifetime "total words checked" framed as cloud sunk-cost-to-lose.** A local milestone counter is fine and good; framing it as "you'll lose your history if you leave" is a retention dark pattern with no place in a no-renewal product.
8. **Unsubstantiated social proof.** `web/src/lib/content.ts` STATS (200k+ downloads, 4.9 rating) and all TESTIMONIALS are explicitly flagged PLACEHOLDER. Do not ship these as fact — replace with real numbers or remove before launch.
Positive reframe to keep: surface locked paid features (modes, app-aware, snippets, larger model) honestly — "Unlock all of this once. Yours forever, no subscription." Same mechanism (visible locked features), stronger close (no recurring cost objection).
---
## 6. Prioritized roadmap
### NOW (trust + the atomic loop — ship-blockers for credibility)
1. **Fix the casing/whitespace post-processing bug** (§3.1). `SystemGrammarEngine.swift`. *high / S.*
2. **Stop bulk-applying no-ops and low-confidence guesses** (§3.2). `GrammarEngine.swift`, `SystemGrammarEngine.swift`. *high / S.*
3. **Dismiss / Ignore / Add-to-dictionary + keyboard accept in the Live Check panel** (Mechanic 2). `LiveCheckView.swift`, `LiveMonitor.swift`, `Models.swift`, `PersonalDictionary.swift`. *high / M.*
4. **Targeted span apply via AX selection instead of whole-field overwrite; rebase instead of bail** (Mechanic 2). `LiveMonitor.swift`, `TextCapture`. *high / M.*
5. **First-run rigged-win demo** (§4). `OnboardingView.swift`. *high / M.*
### NEXT (organize + the value loop)
6. **Named buckets + per-category count chips + severity ordering + filter** (Mechanic 3). `Models.swift`, `LiveMonitor.swift`, `LiveCheckView.swift`. *high / M.*
7. **On-device writing score (per-doc) + dashboard trend** (Mechanics 3 & 5). `WritingScore` helper, `LiveCheckView.swift`, `DashboardView.swift`. *high / M.*
8. **Streaks + milestones + "subscription you didn't pay" money figure + menu-bar stat** (Mechanic 5). `UsageStats.swift`, `DashboardView.swift`, `MenuBarContent.swift`. *high / M.*
9. **Enrich `Suggestion` with confidence/severity/alternatives + "other suggestions" in card** (§3.3). `Models.swift`, `SystemGrammarEngine.swift`, `LiveCheckView.swift`. *medium / M.*
### LATER (real engine + real partner layer)
10. **Real Harper FFI integration + populate `.punctuation` + grapheme safety + incremental async re-check** (§3.4–§3.6). `harper-ffi/`, `HarperEngine.swift`, `SystemGrammarEngine.swift`, `LiveMonitor.swift`, `Package.swift`. *high / L.*
11. **Wire the real MLX model (load downloaded weights) + one-click/reversible/before-after diff for rewrites + real tone detection** (Mechanic 6). `MLXEngine.swift`, `ModelManager.swift`, `Package.swift`, `TextReplacementCoordinator.swift`. *high / L.*
12. **Replace PLACEHOLDER web social proof/testimonials before launch** (§5). `web/src/lib/content.ts`. *medium / S.*
---
*Throughline: ship trust first (correction quality + a safe, reversible accept loop), then make the work feel finite and rewarding (buckets, counts, a local score), then sell ongoing value honestly (self-vs-self progress, money saved, lifetime ownership) — and build the generative partner tier on a real model with a reversible, before/after contract. Every Grammarly mechanic translates if you swap cloud-surveillance for on-device self-comparison and recurring-nag for one-time honesty.*

5617
harper-ffi/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

21
harper-ffi/Cargo.toml Normal file
View File

@ -0,0 +1,21 @@
[package]
name = "harper-ffi"
version = "0.1.0"
edition = "2021"
[lib]
# Static library exposing a C ABI for linking into the Swift app.
name = "harper_ffi"
crate-type = ["staticlib"]
[dependencies]
# harper-core 2.5.0 — the grammar checker from the Automattic Harper project.
# Verified against https://docs.rs/harper-core/2.5.0/
harper-core = "2.5.0"
# Used to build the output JSON with correct string escaping.
serde_json = "1"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1

View File

@ -5,7 +5,7 @@ A thin Rust static library that exposes the **Harper** grammar core
integration (spec §1, §2, §8). integration (spec §1, §2, §8).
The Swift side talks to this through the `GrammarEngine` protocol; today The Swift side talks to this through the `GrammarEngine` protocol; today
[`HarperEngine.swift`](../Sources/GrammaGem/Grammar/HarperEngine.swift) ships a [`HarperEngine.swift`](../Sources/GrammarGem/Grammar/HarperEngine.swift) ships a
small pure-Swift rule stub so the system loop + tests run with zero native deps. small pure-Swift rule stub so the system loop + tests run with zero native deps.
## Planned shape ## Planned shape

26
harper-ffi/build.sh Executable file
View File

@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Build the UNIVERSAL (Apple Silicon + Intel) Harper static library that the
# Swift app links against: harper-ffi/lib/libharper_ffi.a
#
# Requires the Rust toolchain (rustup) with both macOS targets:
# rustup target add aarch64-apple-darwin x86_64-apple-darwin
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT"
# Make cargo available even in non-login shells.
[ -f "$HOME/.cargo/env" ] && source "$HOME/.cargo/env"
echo "==> cargo build --release (aarch64 + x86_64)"
cargo build --release --target aarch64-apple-darwin
cargo build --release --target x86_64-apple-darwin
echo "==> lipo -> lib/libharper_ffi.a (universal)"
mkdir -p lib
lipo -create -output lib/libharper_ffi.a \
target/aarch64-apple-darwin/release/libharper_ffi.a \
target/x86_64-apple-darwin/release/libharper_ffi.a
lipo -info lib/libharper_ffi.a
echo "==> done"

View File

@ -0,0 +1,56 @@
/*
* harper.h — C ABI for the harper-ffi static library (harper-core 2.5.0).
*
* Link against libharper_ffi.a. All strings are UTF-8.
*/
#ifndef HARPER_FFI_H
#define HARPER_FFI_H
#ifdef __cplusplus
extern "C" {
#endif
/*
* Check `text` for grammar/spelling issues using harper-core.
*
* Returns a malloc'd, NUL-terminated UTF-8 JSON C string that the caller MUST
* free with harper_free().
*
* The JSON is an array of objects:
* [
* {
* "start": <integer, char (Unicode scalar) offset into the input>,
* "len": <integer, char count of the offending span>,
* "replacement": "<string>",
* "message": "<string>",
* "kind": "<string>"
* },
* ...
* ]
*
* Notes on the contract:
* - "start"/"len" are CHAR (Unicode scalar) offsets, matching harper's native
* char indexing. They are NOT byte offsets and NOT UTF-16 code-unit offsets.
* "len" = span.end - span.start (end-exclusive).
* - "replacement" is the FIRST ReplaceWith suggestion's text. If the first
* actionable suggestion is a removal, "replacement" is "" (empty string).
* - Lints with NO actionable suggestion are SKIPPED.
* - "kind" is one of: "spelling", "grammar", "punctuation", "repetition",
* "capitalization", "phrasing". Best-effort; defaults to "grammar".
*
* On a null `text`, invalid UTF-8, or any internal error/panic, returns "[]".
* Never returns NULL on a normal return path. Thread-safe.
*/
char *harper_check(const char *text);
/*
* Free a string previously returned by harper_check().
* Passing NULL is a no-op. Do not free the same pointer twice.
*/
void harper_free(char *ptr);
#ifdef __cplusplus
} /* extern "C" */
#endif
#endif /* HARPER_FFI_H */

170
harper-ffi/src/lib.rs Normal file
View File

@ -0,0 +1,170 @@
//! C-ABI FFI bridge exposing harper-core 2.5.0's grammar checker to Swift.
//!
//! Public surface:
//! char* harper_check(const char* text); // malloc'd UTF-8 JSON, free with harper_free
//! void harper_free(char* ptr);
//!
//! Every API call below was written against docs.rs for harper-core 2.5.0.
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::{Arc, OnceLock};
use harper_core::{Dialect, Document};
use harper_core::linting::{LintGroup, LintKind, Linter, Suggestion};
use harper_core::spell::FstDictionary;
/// Empty JSON array used for null/invalid input and on any panic.
const EMPTY_JSON: &str = "[]";
/// The curated FST dictionary is the expensive artifact to build, so build it
/// exactly once and share it across calls/threads via `Arc`. `FstDictionary`
/// is immutable and `Arc<FstDictionary>` is `Send + Sync`, so this is safe to
/// read from any thread.
///
/// We intentionally do NOT cache the `LintGroup` itself: `Linter::lint` takes
/// `&mut self`, which would require a `Mutex` and serialize all callers. The
/// `LintGroup` is cheap to reconstruct relative to the dictionary, so we build
/// a fresh one per call from the shared dictionary instead.
fn curated_dictionary() -> Arc<FstDictionary> {
static DICT: OnceLock<Arc<FstDictionary>> = OnceLock::new();
DICT.get_or_init(FstDictionary::curated).clone()
}
/// Map a harper `LintKind` to the lowercase string contract:
/// one of spelling, grammar, punctuation, repetition, capitalization, phrasing.
/// Default is "grammar" for anything not specifically categorized.
fn kind_to_str(kind: LintKind) -> &'static str {
match kind {
LintKind::Spelling | LintKind::Typo => "spelling",
LintKind::Punctuation | LintKind::Formatting => "punctuation",
LintKind::Repetition => "repetition",
LintKind::Capitalization => "capitalization",
// "phrasing"-flavored categories: word choice / style / readability / usage.
LintKind::WordChoice
| LintKind::Style
| LintKind::Readability
| LintKind::Usage
| LintKind::Redundancy
| LintKind::Enhancement
| LintKind::Eggcorn
| LintKind::Malapropism
| LintKind::Nonstandard
| LintKind::Regionalism => "phrasing",
// Grammar-ish / structural categories.
LintKind::Grammar
| LintKind::Agreement
| LintKind::BoundaryError
| LintKind::Miscellaneous => "grammar",
// LintKind may gain variants; default safely.
_ => "grammar",
}
}
/// Run harper over `text` and produce the JSON contract string.
fn check_to_json(text: &str) -> String {
let dictionary = curated_dictionary();
// Build the document with the built-in PlainEnglish parser + curated dictionary.
let document = Document::new_plain_english(text, &dictionary);
// Fresh curated lint group from the shared dictionary; American dialect.
let mut group = LintGroup::new_curated(dictionary.clone(), Dialect::American);
// Linter::lint(&mut self, &Document) -> Vec<Lint>
let lints = group.lint(&document);
let mut out: Vec<serde_json::Value> = Vec::with_capacity(lints.len());
for lint in lints {
// span is Span<char>: start/end are CHAR (Unicode scalar) offsets, end-exclusive.
let start = lint.span.start;
let len = lint.span.end.saturating_sub(lint.span.start);
// Pick the first ReplaceWith suggestion as "replacement".
// If the first actionable suggestion is a Remove, replacement = "".
// InsertAfter is treated as non-replacement here. Lints with no
// actionable suggestion are skipped.
let mut replacement: Option<String> = None;
for suggestion in &lint.suggestions {
match suggestion {
Suggestion::ReplaceWith(chars) => {
replacement = Some(chars.iter().collect());
break;
}
Suggestion::Remove => {
replacement = Some(String::new());
break;
}
Suggestion::InsertAfter(_) => {}
}
}
let replacement = match replacement {
Some(r) => r,
None => continue,
};
out.push(serde_json::json!({
"start": start,
"len": len,
"replacement": replacement,
"message": lint.message,
"kind": kind_to_str(lint.lint_kind),
}));
}
serde_json::to_string(&out).unwrap_or_else(|_| EMPTY_JSON.to_string())
}
/// Allocate a C string for return to the caller. The caller owns it and must
/// free it with `harper_free`.
fn into_c_string(s: String) -> *mut c_char {
match CString::new(s) {
Ok(cstr) => cstr.into_raw(),
Err(_) => CString::new(EMPTY_JSON).unwrap().into_raw(),
}
}
/// Check `text` for grammar/spelling issues.
///
/// Returns a malloc'd, NUL-terminated UTF-8 JSON C string the caller MUST free
/// with `harper_free`. On null/invalid-UTF-8 input or on any internal panic,
/// returns the JSON `"[]"`. Never returns null on a normal return path.
///
/// # Safety
/// `text` must be a valid NUL-terminated C string pointer or null.
#[no_mangle]
pub extern "C" fn harper_check(text: *const c_char) -> *mut c_char {
let result = catch_unwind(AssertUnwindSafe(|| {
if text.is_null() {
return EMPTY_JSON.to_string();
}
// SAFETY: caller guarantees a valid NUL-terminated C string (non-null checked).
let c_str = unsafe { CStr::from_ptr(text) };
match c_str.to_str() {
Ok(s) => check_to_json(s),
Err(_) => EMPTY_JSON.to_string(),
}
}));
match result {
Ok(json) => into_c_string(json),
Err(_) => into_c_string(EMPTY_JSON.to_string()),
}
}
/// Free a string previously returned by `harper_check`.
///
/// # Safety
/// `ptr` must be a pointer returned by `harper_check` (or null), freed once.
#[no_mangle]
pub extern "C" fn harper_free(ptr: *mut c_char) {
if ptr.is_null() {
return;
}
// SAFETY: ptr came from CString::into_raw in this module; reclaim and drop it.
unsafe {
drop(CString::from_raw(ptr));
}
}

25
scripts/build-metallib.sh Executable file
View File

@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Regenerate AppSupport/mlx.metallib — the MLX on-device LLM's compiled Metal
# kernels.
#
# `swift build` (SwiftPM CLI) does NOT compile mlx-swift's .metal sources into a
# metallib, but Xcode's build system does. So we build the package once via
# xcodebuild, then vendor the resulting metallib (shipped colocated with the app
# executable; see scripts/build.sh). Run this when bumping the mlx-swift version.
#
# Requires the Metal Toolchain: xcodebuild -downloadComponent MetalToolchain
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
DD=".xcode-build"
echo "==> xcodebuild (compiles mlx-swift metal kernels — a few minutes)"
xcodebuild build -scheme GrammarGem -configuration Release \
-destination 'platform=macOS,arch=arm64' \
-derivedDataPath "$DD" -skipMacroValidation >/dev/null
SRC="$DD/Build/Products/Release/mlx-swift_Cmlx.bundle/Contents/Resources/default.metallib"
[ -f "$SRC" ] || { echo "ERROR: metallib not produced at $SRC" >&2; exit 1; }
cp "$SRC" AppSupport/mlx.metallib
echo "==> vendored AppSupport/mlx.metallib ($(du -h AppSupport/mlx.metallib | cut -f1))"

View File

@ -1,5 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Build a runnable, UNIVERSAL (Apple Silicon + Intel) GrammaGem.app bundle. # Build a runnable GrammarGem.app bundle (arm64 / Apple Silicon — the MLX
# on-device LLM runtime is Metal-only, so Intel is not supported).
# Usage: ./scripts/build.sh (run from the mac/ directory) # Usage: ./scripts/build.sh (run from the mac/ directory)
set -euo pipefail set -euo pipefail
@ -7,21 +8,52 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT" cd "$ROOT"
CONFIG="release" CONFIG="release"
APP="GrammaGem.app" APP="GrammarGem.app"
DIST="dist" DIST="dist"
echo "==> Compiling universal (arm64 + x86_64, ${CONFIG})" echo "==> Building Harper static lib (universal)"
swift build -c "${CONFIG}" --arch arm64 --arch x86_64 chmod +x harper-ffi/build.sh
BIN="$(swift build -c "${CONFIG}" --arch arm64 --arch x86_64 --show-bin-path)" ./harper-ffi/build.sh
echo "==> Compiling (arm64 / Apple Silicon, ${CONFIG})"
# arm64-only: the MLX on-device LLM runtime is Apple-Silicon / Metal only.
swift build -c "${CONFIG}" --arch arm64
BIN="$(swift build -c "${CONFIG}" --arch arm64 --show-bin-path)"
echo "==> Assembling ${APP}" echo "==> Assembling ${APP}"
rm -rf "${DIST}/${APP}" rm -rf "${DIST}/${APP}"
mkdir -p "${DIST}/${APP}/Contents/MacOS" "${DIST}/${APP}/Contents/Resources" mkdir -p "${DIST}/${APP}/Contents/MacOS" "${DIST}/${APP}/Contents/Resources"
cp "${BIN}/GrammaGem" "${DIST}/${APP}/Contents/MacOS/GrammaGem" cp "${BIN}/GrammarGem" "${DIST}/${APP}/Contents/MacOS/GrammarGem"
cp "AppSupport/Info.plist" "${DIST}/${APP}/Contents/Info.plist" cp "AppSupport/Info.plist" "${DIST}/${APP}/Contents/Info.plist"
cp "AppSupport/AppIcon.icns" "${DIST}/${APP}/Contents/Resources/AppIcon.icns" cp "AppSupport/AppIcon.icns" "${DIST}/${APP}/Contents/Resources/AppIcon.icns"
# SwiftPM resource bundles (tokenizer data, etc.) go in Contents/Resources so
# Bundle.module resolves via the main bundle and the signature stays valid.
shopt -s nullglob
for b in "${BIN}"/*.bundle; do
cp -R "$b" "${DIST}/${APP}/Contents/Resources/"
done
shopt -u nullglob
# MLX Metal kernels: `swift build` (CLI) does NOT compile mlx-swift's .metal
# sources, so we ship a prebuilt metallib in the exact bundle mlx looks for —
# Contents/Resources/mlx-swift_Cmlx.bundle/Contents/Resources/default.metallib —
# which mlx finds via the main bundle's resources and which codesigns cleanly.
# Regenerate AppSupport/mlx.metallib with: ./scripts/build-metallib.sh
MLX_RES="${DIST}/${APP}/Contents/Resources/mlx-swift_Cmlx.bundle/Contents/Resources"
mkdir -p "${MLX_RES}"
cp "AppSupport/mlx.metallib" "${MLX_RES}/default.metallib"
# Embed Sparkle.framework (auto-update) and add an rpath so the executable can
# resolve it at @executable_path/../Frameworks. (Signed inside-out by sign-app.sh.)
echo "==> Embedding Sparkle.framework"
SPARKLE_FW="$(find .build -path '*Sparkle.xcframework/macos-arm64*/Sparkle.framework' -type d | head -1)"
mkdir -p "${DIST}/${APP}/Contents/Frameworks"
cp -R "${SPARKLE_FW}" "${DIST}/${APP}/Contents/Frameworks/"
install_name_tool -add_rpath "@executable_path/../Frameworks" \
"${DIST}/${APP}/Contents/MacOS/GrammarGem" 2>/dev/null || true
echo "==> Built ${DIST}/${APP}" echo "==> Built ${DIST}/${APP}"
lipo -info "${DIST}/${APP}/Contents/MacOS/GrammaGem" lipo -info "${DIST}/${APP}/Contents/MacOS/GrammarGem"
echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)" echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)"

View File

@ -1,5 +1,5 @@
#!/usr/bin/env swift #!/usr/bin/env swift
// Generates AppIcon.iconset (PNGs) for GrammaGem — a gold pen-nib on the ink // Generates AppIcon.iconset (PNGs) for GrammarGem — a gold pen-nib on the ink
// brand square. Run from mac/: swift scripts/make-icon.swift // brand square. Run from mac/: swift scripts/make-icon.swift
// Then: iconutil -c icns AppIcon.iconset -o AppSupport/AppIcon.icns // Then: iconutil -c icns AppIcon.iconset -o AppSupport/AppIcon.icns
import AppKit import AppKit

View File

@ -1,32 +1,71 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Package a signed build for distribution and update the Sparkle appcast hosted # Cut a GrammarGem release and publish the Sparkle appcast (hosted on grammargem.com).
# on GitHub Releases (zero hosting cost). Run after sign-notarize.sh.
# #
# This is a documented scaffold — wire it to your Sparkle EdDSA key + repo. # ./scripts/release.sh <version> e.g. ./scripts/release.sh 0.2.0
#
# Steps: stamp version -> build -> sign inside-out (incl. Sparkle) -> notarize +
# staple -> zip -> generate the EdDSA-signed appcast.xml. Then upload the .zip and
# appcast.xml to grammargem.com so every installed app shows "Update available".
#
# Notarization needs ONE of:
# NOTARY_PROFILE (xcrun notarytool store-credentials)
# APPLE_ID + TEAM_ID + NOTARY_PASSWORD (Apple ID + app-specific password)
# Set SKIP_NOTARIZE=1 for a dry run (un-notarized build; do not ship).
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
APP="$ROOT/dist/GrammaGem.app" cd "$ROOT"
VERSION="${1:?Usage: release.sh <version> e.g. 0.1.0}"
DMG="$ROOT/dist/GrammaGem-$VERSION.dmg"
echo "▸ Creating DMG for $VERSION…" VERSION="${1:?usage: release.sh <version> e.g. release.sh 0.2.0}"
# hdiutil create -volname "GrammaGem" -srcfolder "$APP" -ov -format UDZO "$DMG" # TODO: enable APP="dist/GrammarGem.app"
echo " (DMG creation stubbed — uncomment hdiutil line)" RELDIR="dist/releases"
ZIP="${RELDIR}/GrammarGem-${VERSION}.zip"
DL_PREFIX="${DL_PREFIX:-https://www.grammargem.com/releases/}"
PB=/usr/libexec/PlistBuddy
cat <<'NOTE' # 1. Stamp the marketing version + bump the monotonic build number.
▸ Sparkle appcast (TODO): CUR_BUILD="$($PB -c 'Print :CFBundleVersion' AppSupport/Info.plist)"
1. Sign the update: ./bin/sign_update dist/GrammaGem-<ver>.dmg NEW_BUILD=$((CUR_BUILD + 1))
(Sparkle's sign_update tool, using your EdDSA private key) $PB -c "Set :CFBundleShortVersionString ${VERSION}" AppSupport/Info.plist
2. Add an <item> to appcast.xml with the version, URL (GitHub Releases asset), $PB -c "Set :CFBundleVersion ${NEW_BUILD}" AppSupport/Info.plist
length, and sparkle:edSignature. echo "==> Releasing ${VERSION} (build ${NEW_BUILD})"
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): # 2. Build + sign (inside-out so the embedded Sparkle.framework is valid).
- Model weights: Hugging Face CDN ./scripts/build.sh
- Updates: GitHub Releases + Sparkle ./scripts/sign-app.sh "$APP"
- Licensing: Lemon Squeezy License API (% per sale only)
NOTE
echo "✓ Release steps printed for $VERSION" # 3. Zip.
mkdir -p "$RELDIR"
rm -f "$ZIP"
ditto -c -k --keepParent "$APP" "$ZIP"
# 4. Notarize + staple (the ticket lets Sparkle install it without Gatekeeper prompts).
if [ "${SKIP_NOTARIZE:-0}" != "1" ]; then
echo "==> Notarizing"
if [ -n "${NOTARY_PROFILE:-}" ]; then
xcrun notarytool submit "$ZIP" --keychain-profile "$NOTARY_PROFILE" --wait
elif [ -n "${APPLE_ID:-}" ] && [ -n "${TEAM_ID:-}" ] && [ -n "${NOTARY_PASSWORD:-}" ]; then
xcrun notarytool submit "$ZIP" \
--apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$NOTARY_PASSWORD" --wait
else
echo "ERROR: set NOTARY_PROFILE or APPLE_ID+TEAM_ID+NOTARY_PASSWORD (or SKIP_NOTARIZE=1)." >&2
exit 1
fi
xcrun stapler staple "$APP"
rm -f "$ZIP"; ditto -c -k --keepParent "$APP" "$ZIP" # re-zip the stapled app
fi
# 5. Generate the EdDSA-signed appcast (keeps every zip in RELDIR in its history).
GA="$(find .build/artifacts -name generate_appcast -type f | head -1)"
[ -n "$GA" ] || { echo "ERROR: generate_appcast not found (run 'swift package resolve')." >&2; exit 1; }
"$GA" --download-url-prefix "$DL_PREFIX" "$RELDIR"
echo
echo "✓ Release ${VERSION} built. Upload these to grammargem.com:"
echo " ${ZIP}"
echo " -> ${DL_PREFIX}GrammarGem-${VERSION}.zip"
echo " ${RELDIR}/appcast.xml"
echo " -> https://www.grammargem.com/appcast.xml (matches Info.plist SUFeedURL)"
echo
echo " Once uploaded, installed apps show an 'Update available' button within a day"
echo " (or immediately via menu bar -> Check for Updates...)."

35
scripts/sign-app.sh Executable file
View File

@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Code-sign GrammarGem.app with Developer ID + Hardened Runtime, signing the
# embedded Sparkle.framework's nested helpers INSIDE-OUT first (required for a
# valid, notarizable signature).
#
# Usage: ./scripts/sign-app.sh [path/to/GrammarGem.app]
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
DEV_ID="${DEV_ID:-Developer ID Application: Marc Schultheiss (D5KT5B9Z9M)}"
ENTITLEMENTS="AppSupport/GrammarGem.entitlements"
APP="${1:-dist/GrammarGem.app}"
sign() { codesign --force --timestamp --options runtime --sign "$DEV_ID" "$@"; }
FW="${APP}/Contents/Frameworks/Sparkle.framework"
if [ -d "$FW" ]; then
echo "==> Signing Sparkle.framework (inside-out)"
V="${FW}/Versions/B"
for xpc in "$V"/XPCServices/*.xpc; do [ -e "$xpc" ] && sign "$xpc"; done
[ -e "$V/Updater.app/Contents/MacOS/Autoupdate" ] && sign "$V/Updater.app/Contents/MacOS/Autoupdate" || true
[ -e "$V/Updater.app" ] && sign "$V/Updater.app"
[ -e "$V/Autoupdate" ] && sign "$V/Autoupdate"
sign "$FW"
fi
echo "==> Signing ${APP}"
codesign --force --timestamp --options runtime \
--entitlements "$ENTITLEMENTS" --sign "$DEV_ID" "$APP"
echo "==> Verifying"
codesign --verify --strict --verbose=2 "$APP"
echo "✓ signed: $APP"

View File

@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Build (universal), codesign (Hardened Runtime + Developer ID), notarize, and # Build (universal), codesign (Hardened Runtime + Developer ID), notarize, and
# staple GrammaGem.app. Requires the Apple Developer Program ($99/yr) — a FIXED # staple GrammarGem.app. Requires the Apple Developer Program ($99/yr) — a FIXED
# cost, not per-user. # cost, not per-user.
# #
# Credentials are read from the environment or a stored keychain profile — never # Credentials are read from the environment or a stored keychain profile — never
@ -15,19 +15,15 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT" cd "$ROOT"
DEV_ID="${DEV_ID:-Developer ID Application: Marc Schultheiss (D5KT5B9Z9M)}" DEV_ID="${DEV_ID:-Developer ID Application: Marc Schultheiss (D5KT5B9Z9M)}"
APP="dist/GrammaGem.app" APP="dist/GrammarGem.app"
ZIP="dist/GrammaGem.zip" ZIP="dist/GrammarGem.zip"
ENTITLEMENTS="AppSupport/GrammaGem.entitlements" ENTITLEMENTS="AppSupport/GrammarGem.entitlements"
echo "==> Building universal bundle" echo "==> Building universal bundle"
./scripts/build.sh >/dev/null ./scripts/build.sh >/dev/null
echo "==> Codesigning (Hardened Runtime)" echo "==> Codesigning (Hardened Runtime, inside-out incl. Sparkle)"
codesign --force --timestamp --options runtime \ DEV_ID="$DEV_ID" ./scripts/sign-app.sh "$APP"
--entitlements "$ENTITLEMENTS" \
--sign "$DEV_ID" \
"$APP"
codesign --verify --strict --verbose=2 "$APP"
echo "==> Zipping" echo "==> Zipping"
ditto -c -k --keepParent "$APP" "$ZIP" ditto -c -k --keepParent "$APP" "$ZIP"