This commit is contained in:
BigMarc 2026-06-18 16:49:59 +02:00
parent 3d749e8139
commit e5b0977abf
45 changed files with 145 additions and 145 deletions

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>
@ -44,11 +44,11 @@
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string>NSApplication</string> <string>NSApplication</string>
<!-- Sparkle auto-update. Appcast is served from grammagem.app and updates are <!-- 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 EdDSA-signed with the key whose public half is below (private key lives in
the release machine's keychain; sign with scripts/release.sh). --> the release machine's keychain; sign with scripts/release.sh). -->
<key>SUFeedURL</key> <key>SUFeedURL</key>
<string>https://grammagem.app/appcast.xml</string> <string>https://www.grammargem.com/appcast.xml</string>
<key>SUPublicEDKey</key> <key>SUPublicEDKey</key>
<string>IwYch+DRgNk3gmCwe86z+SIc4sRdyDf4Z6xGpS+JXDc=</string> <string>IwYch+DRgNk3gmCwe86z+SIc4sRdyDf4Z6xGpS+JXDc=</string>
<key>SUEnableAutomaticChecks</key> <key>SUEnableAutomaticChecks</key>

View File

@ -2,7 +2,7 @@
import PackageDescription import PackageDescription
import Foundation import Foundation
// GrammaGem — native macOS menu-bar writing assistant. // GrammarGem — native macOS menu-bar writing assistant.
// //
// Layer-1 grammar is the real **Harper** core (Apache-2.0), embedded as a Rust // Layer-1 grammar is the real **Harper** core (Apache-2.0), embedded as a Rust
// C-FFI static library (see `harper-ffi/`). Build the lib first with // C-FFI static library (see `harper-ffi/`). Build the lib first with
@ -18,41 +18,41 @@ let packageRoot = URL(fileURLWithPath: #filePath).deletingLastPathComponent().pa
let harperLibDir = packageRoot + "/harper-ffi/lib" 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: [
// On-device LLM runtime. Pinned to an exact tag (the LLM libraries still // On-device LLM runtime. Pinned to an exact tag (the LLM libraries still
// ship from this repo at 2.25.9; the package name is "mlx-libraries"). // ship from this repo at 2.25.9; the package name is "mlx-libraries").
.package(url: "https://github.com/ml-explore/mlx-swift-examples.git", exact: "2.25.9"), .package(url: "https://github.com/ml-explore/mlx-swift-examples.git", exact: "2.25.9"),
// Secure auto-updates (EdDSA-signed appcast served from grammagem.app). // Secure auto-updates (EdDSA-signed appcast served from grammargem.com).
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"), .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. // C shim exposing libharper_ffi's C ABI (harper-ffi/include/harper.h) to Swift.
.target(name: "CHarper", path: "Sources/CHarper"), .target(name: "CHarper", path: "Sources/CHarper"),
.executableTarget( .executableTarget(
name: "GrammaGem", name: "GrammarGem",
dependencies: [ dependencies: [
"CHarper", "CHarper",
.product(name: "MLXLLM", package: "mlx-swift-examples"), .product(name: "MLXLLM", package: "mlx-swift-examples"),
.product(name: "MLXLMCommon", package: "mlx-swift-examples"), .product(name: "MLXLMCommon", package: "mlx-swift-examples"),
.product(name: "Sparkle", package: "Sparkle"), .product(name: "Sparkle", package: "Sparkle"),
], ],
path: "Sources/GrammaGem", path: "Sources/GrammarGem",
linkerSettings: [ linkerSettings: [
// Link the prebuilt Harper static library (arm64 slice). // Link the prebuilt Harper static library (arm64 slice).
.unsafeFlags(["-L\(harperLibDir)", "-lharper_ffi"]) .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

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

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() {

View File

@ -38,7 +38,7 @@ final class ModelManager: ObservableObject {
nonisolated static func modelsDirectory() -> URL { nonisolated static func modelsDirectory() -> URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
return base.appendingPathComponent("GrammaGem/Models", isDirectory: true) return base.appendingPathComponent("GrammarGem/Models", isDirectory: true)
} }
nonisolated static func modelDir(_ repo: String) -> URL { nonisolated static func modelDir(_ repo: String) -> URL {

View File

@ -115,7 +115,7 @@ final class AppState: ObservableObject {
} }
Task { await license.validateIfNeeded() } Task { await license.validateIfNeeded() }
Log.app.info("GrammaGem started. Licensed: \(self.license.isLicensed, privacy: .public)") Log.app.info("GrammarGem started. Licensed: \(self.license.isLicensed, privacy: .public)")
} }
// MARK: - Hotkey handling // MARK: - Hotkey handling
@ -205,19 +205,19 @@ final class AppState: ObservableObject {
return decision return decision
} }
/// Pause/resume all of GrammaGem (hotkeys + live monitoring). /// 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
} }
@ -282,7 +282,7 @@ 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: 560, height: 720)) win.setContentSize(NSSize(width: 560, height: 720))
win.isReleasedWhenClosed = false win.isReleasedWhenClosed = false
@ -311,7 +311,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)
@ -349,7 +349,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

@ -5,7 +5,7 @@ import AppKit
/// and runs one real generation (verifies the MLX runtime + metallib headlessly), /// and runs one real generation (verifies the MLX runtime + metallib headlessly),
/// then exits. Otherwise the normal menu-bar app launches. /// then exits. Otherwise the normal menu-bar app launches.
@main @main
enum GrammaGemMain { enum GrammarGemMain {
static func main() { static func main() {
if CommandLine.arguments.contains("--mlx-selftest") { if CommandLine.arguments.contains("--mlx-selftest") {
MLXSelfTest.run() MLXSelfTest.run()
@ -16,7 +16,7 @@ enum GrammaGemMain {
UpdaterSelfTest.run(feedURL: feed) UpdaterSelfTest.run(feedURL: feed)
return return
} }
GrammaGemApp.main() GrammarGemApp.main()
} }
} }
@ -47,7 +47,7 @@ enum MLXSelfTest {
/// App entry point. A menu-bar (`LSUIElement`) SwiftUI app: the only persistent /// App entry point. A menu-bar (`LSUIElement`) SwiftUI app: the only persistent
/// scene is the `MenuBarExtra`; Settings uses the standard scene; Onboarding and /// scene is the `MenuBarExtra`; Settings uses the standard scene; Onboarding and
/// Ask are AppKit-hosted windows opened on demand (see `AppState`). /// Ask are AppKit-hosted windows opened on demand (see `AppState`).
struct GrammaGemApp: App { struct GrammarGemApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@StateObject private var app = AppState.shared @StateObject private var app = AppState.shared

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

@ -91,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

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

@ -49,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 {
@ -57,14 +57,14 @@ 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() } actionRow(title: "Check for Updates…", shortcut: nil) { app.updater.checkForUpdates() }
@ -90,7 +90,7 @@ struct MenuBarContent: View {
.frame(width: 290) .frame(width: 290)
} }
/// Shown when Sparkle has found a newer signed build on grammagem.app. /// Shown when Sparkle has found a newer signed build on grammargem.com.
private func updateBanner(version: String) -> some View { private func updateBanner(version: String) -> some View {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "arrow.down.circle.fill").foregroundStyle(GG.emerald) Image(systemName: "arrow.down.circle.fill").foregroundStyle(GG.emerald)
@ -110,7 +110,7 @@ struct MenuBarContent: 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)
} }

View File

@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
/// First-run onboarding: explains why GrammaGem needs Accessibility, deep-links /// First-run onboarding: explains why GrammarGem needs Accessibility, deep-links
/// to System Settings, and advances automatically the moment permission is /// to System Settings, and advances automatically the moment permission is
/// granted (even if granted directly in System Settings). /// granted (even if granted directly in System Settings).
struct OnboardingView: View { struct OnboardingView: View {
@ -18,7 +18,7 @@ struct OnboardingView: View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("Welcome to GrammaGem") Text("Welcome to GrammarGem")
.font(.largeTitle.bold()) .font(.largeTitle.bold())
Text("A private, on-device writing assistant that works in every app.") Text("A private, on-device writing assistant that works in every app.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@ -36,7 +36,7 @@ struct OnboardingView: View {
stepCard( stepCard(
number: 1, number: 1,
title: "Turn it on everywhere", title: "Turn it on everywhere",
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.", 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 granted: permissions.accessibilityTrusted
) { ) {
if permissions.accessibilityTrusted { if permissions.accessibilityTrusted {
@ -47,7 +47,7 @@ struct OnboardingView: View {
Button("Open System Settings") { permissions.openAccessibilitySettings() } Button("Open System Settings") { permissions.openAccessibilitySettings() }
Button("Request permission") { permissions.requestAccessibility() } Button("Request permission") { permissions.requestAccessibility() }
} }
Text("This updates automatically once you flip GrammaGem on in Accessibility.") Text("This updates automatically once you flip GrammarGem on in Accessibility.")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
} }
} }
@ -114,7 +114,7 @@ struct OnboardingView: View {
/// A safe, on-rails first-run demo: a local sample with planted mistakes that /// 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 /// 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 /// (never a live Accessibility element), so it's completely risk-free — the
/// "guaranteed visible win" before GrammaGem asks for anything. /// "guaranteed visible win" before GrammarGem asks for anything.
private struct DemoStep: View { private struct DemoStep: View {
@State private var text = "i has wrote alot of mistakes here, and it dont look profesional" @State private var text = "i has wrote alot of mistakes here, and it dont look profesional"
@State private var issues: [Suggestion] = [] @State private var issues: [Suggestion] = []
@ -151,7 +151,7 @@ private struct DemoStep: View {
} }
private var headline: String { private var headline: String {
if cleaned { return "Clean ✨ — GrammaGem does this in every app you type." } if cleaned { return "Clean ✨ — GrammarGem does this in every app you type." }
if issues.isEmpty { return "Checking…" } if issues.isEmpty { return "Checking…" }
return "We spotted \(issues.count) thing\(issues.count == 1 ? "" : "s") to tidy up — one click fixes them." return "We spotted \(issues.count) thing\(issues.count == 1 ? "" : "s") to tidy up — one click fixes them."
} }

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).

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

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

@ -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,7 +54,7 @@ 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 }

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

@ -14,12 +14,12 @@ final class UsageStats: ObservableObject {
} }
/// What a Grammarly-style subscription runs per year (USD) — used only for the /// What a Grammarly-style subscription runs per year (USD) — used only for the
/// honest, local "subscription you didn't pay" figure. GrammaGem is one-time. /// honest, local "subscription you didn't pay" figure. GrammarGem is one-time.
static let comparableAnnualSubscriptionUSD = 144.0 static let comparableAnnualSubscriptionUSD = 144.0
@Published private(set) var data: Snapshot @Published private(set) var data: Snapshot
private let key = "GrammaGem.usageStats" private let key = "GrammarGem.usageStats"
private let d = UserDefaults.standard private let d = UserDefaults.standard
private static let fmt: DateFormatter = { private static let fmt: DateFormatter = {
@ -64,7 +64,7 @@ final class UsageStats: ObservableObject {
var currentStreak: Int { Self.streak(perDay: data.perDay, asOf: Date()) } var currentStreak: Int { Self.streak(perDay: data.perDay, asOf: Date()) }
/// The Grammarly subscription you DIDN'T pay: their annual price prorated over /// The Grammarly subscription you DIDN'T pay: their annual price prorated over
/// how long you've owned GrammaGem. Honest, local, illustrative — and it only /// 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). /// grows, which is the point of a one-time purchase (see the value plan §5).
var subscriptionAvoidedUSD: Int { var subscriptionAvoidedUSD: Int {
Self.subscriptionAvoidedUSD( Self.subscriptionAvoidedUSD(

View File

@ -43,7 +43,7 @@ enum AX {
/// ///
/// On recent macOS, `AXUIElementCreateSystemWide()` returns /// On recent macOS, `AXUIElementCreateSystemWide()` returns
/// `kAXErrorCannotComplete` (-25204) for *every* query made by a background / /// `kAXErrorCannotComplete` (-25204) for *every* query made by a background /
/// accessory process (which is exactly what a menu-bar app like GrammaGem is). /// 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 /// 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 /// app "sees" no text or text fields at all. Resolving the focused element
/// through the **frontmost application's** own AX element works reliably in /// through the **frontmost application's** own AX element works reliably in

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,7 +19,7 @@ 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 {
@ -179,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 ?? ""

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`
@ -27,7 +27,7 @@ final class TextCapture {
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."
} }
} }
} }

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.
/// ///

View File

@ -1,7 +1,7 @@
import Foundation import Foundation
import Sparkle import Sparkle
/// Wraps Sparkle's updater. It background-checks the appcast on grammagem.app and /// Wraps Sparkle's updater. It background-checks the appcast on grammargem.com and
/// publishes `updateAvailableVersion` when a newer, EdDSA-verified build exists, so /// publishes `updateAvailableVersion` when a newer, EdDSA-verified build exists, so
/// the menu bar can show an "Update available" button. Triggering an update runs /// the menu bar can show an "Update available" button. Triggering an update runs
/// Sparkle's verified download + install flow. /// Sparkle's verified download + install flow.

View File

@ -1,7 +1,7 @@
import XCTest import XCTest
@testable import GrammaGem @testable import GrammarGem
final class GrammaGemTests: XCTestCase { final class GrammarGemTests: XCTestCase {
// MARK: - Tiers / entitlements // MARK: - Tiers / entitlements
@ -117,10 +117,10 @@ final class GrammaGemTests: XCTestCase {
/// MLX integration end-to-end. /// MLX integration end-to-end.
func testMLXRealGenerationIfModelPresent() async throws { func testMLXRealGenerationIfModelPresent() async throws {
// Opt-in: `swift test` (CLI) can't place mlx-swift's metallib, so this is // 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 GRAMMAGEM_MLX_TEST=1 // gated. Run it via the app's `--mlx-selftest`, or set GRAMMARGEM_MLX_TEST=1
// with the metallib colocated. See scripts/build-metallib.sh. // with the metallib colocated. See scripts/build-metallib.sh.
guard ProcessInfo.processInfo.environment["GRAMMAGEM_MLX_TEST"] == "1" else { guard ProcessInfo.processInfo.environment["GRAMMARGEM_MLX_TEST"] == "1" else {
throw XCTSkip("set GRAMMAGEM_MLX_TEST=1 (metallib colocated) to run the MLX integration test") 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 { guard let dir = ModelManager.completedModelDirectory(repo: AppConfig.Model.defaultRepo) else {
throw XCTSkip("on-device model not downloaded; skipping MLX integration test") throw XCTSkip("on-device model not downloaded; skipping MLX integration test")

View File

@ -1,6 +1,6 @@
# Grammarly Value Teardown -> GrammaGem Plan # Grammarly Value Teardown -> GrammarGem Plan
*Senior product + Swift engineering review. Every claim below is grounded in the current GrammaGem source. File references use absolute paths; the load-bearing bug is the mis-casing in `SystemGrammarEngine.swift:42-45`.* *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`.*
--- ---
@ -8,34 +8,34 @@
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: 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. GrammaGem's engine currently *demonstrably violates this* (`problemm` -> `Problem` mid-sentence). Fix this first; nothing else matters until it's solid. 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. GrammaGem has an `Apply` button but no dismiss/ignore/add-to-dictionary, no keyboard accept, and the apply path is brittle (whole-field AX overwrite). 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. GrammaGem has a raw issue count (3 colors) and *zero* score concept. 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. GrammaGem's onboarding only handles Accessibility + model download — it never demonstrates the correction loop at all. 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. GrammaGem 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. 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. 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." GrammaGem's identical mechanics should be framed "yours once, forever, on this Mac." 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 GrammaGem should do it ## 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) ### 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. - **Grammarly:** ranks by confidence, suppresses low-confidence edits, never garbles surrounding text, preserves casing, and limits bulk-apply to high-confidence objective mechanics only.
- **GrammaGem 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`). - **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. - **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/`. - **Files:** `SystemGrammarEngine.swift`, `HarperEngine.swift`, `GrammarEngine.swift`, `Models.swift`, `harper-ffi/`.
- **Impact: high · Effort: L** - **Impact: high · Effort: L**
### Mechanic 2 — The fix-as-button atomic loop (+ dismiss / ignore / add-to-dictionary) ### 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. - **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.
- **GrammaGem 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. - **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):** - **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. - 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 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`.
@ -47,9 +47,9 @@ The strategic asymmetry to exploit throughout: every Grammarly value moment carr
### Mechanic 3 — Categorization, counts, and an on-device document score ### 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. - **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.
- **GrammaGem 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**. - **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):** - **Do (on-device, no cloud, no percentile-vs-others):**
- Introduce GrammaGem'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). - 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`). - 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. - 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. - **Order cards by severity then document position** (objective spelling/grammar first) so the first accept is a high-confidence win.
@ -59,18 +59,18 @@ The strategic asymmetry to exploit throughout: every Grammarly value moment carr
### Mechanic 4 — The first-run "wow" (see §4 for the full sequence) ### 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. - **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.
- **GrammaGem today:** `OnboardingView` only covers Accessibility + model download (`OnboardingView.swift`); it never shows the correction loop. - **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. - **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. - **Files:** `OnboardingView.swift`, plus a small `DemoDocument` view reusing the highlight + apply UI.
- **Impact: high · Effort: M** - **Impact: high · Effort: M**
### Mechanic 5 — Ongoing perceived value (privacy-native, self-vs-self) ### 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. - **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.
- **GrammaGem 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. - **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):** - **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. - **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. - **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 GrammaGem recovered M times over," computed locally. This is the strongest one-time-purchase value teaser and it lives where the user actually is. - **"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). - **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. - **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. - Tune `estimatedMinutesSaved` to scale by words changed / action type so the headline "was it worth it?" number is credible.
@ -79,7 +79,7 @@ The strategic asymmetry to exploit throughout: every Grammarly value moment carr
### Mechanic 6 — The generative "writing partner" layer (real LLM, honest gating) ### 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. - **Grammarly:** sentence rewrites ("Replace with This Version" + Highlight-Changes diff), tone detection (emoji + label), tone adjustment, conciseness, GrammarlyGO — all one-click, reversible, before/after.
- **GrammaGem 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. - **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:** - **Do:**
- Wire the **real MLX model** from `ModelManager.modelsDirectory` (the download is currently a dead-end); uncomment `mlx-swift` in `Package.swift`. - 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." - Adopt the **one-click + reversible + before/after diff** delivery contract for every rewrite — this is what neutralizes "the AI is taking over my voice."
@ -133,7 +133,7 @@ Wire dismiss / add-to-dictionary (Mechanic 2) back into ranking so repeated fals
--- ---
## 4. The first-run "wow" sequence for GrammaGem (original copy) ## 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. 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.
@ -145,7 +145,7 @@ Proposed flow:
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.) 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 GrammaGem. 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." 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. 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.

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

View File

@ -15,7 +15,7 @@ cd "$ROOT"
DD=".xcode-build" DD=".xcode-build"
echo "==> xcodebuild (compiles mlx-swift metal kernels — a few minutes)" echo "==> xcodebuild (compiles mlx-swift metal kernels — a few minutes)"
xcodebuild build -scheme GrammaGem -configuration Release \ xcodebuild build -scheme GrammarGem -configuration Release \
-destination 'platform=macOS,arch=arm64' \ -destination 'platform=macOS,arch=arm64' \
-derivedDataPath "$DD" -skipMacroValidation >/dev/null -derivedDataPath "$DD" -skipMacroValidation >/dev/null

View File

@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Build a runnable GrammaGem.app bundle (arm64 / Apple Silicon — the MLX # Build a runnable GrammarGem.app bundle (arm64 / Apple Silicon — the MLX
# on-device LLM runtime is Metal-only, so Intel is not supported). # 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
@ -8,7 +8,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT" cd "$ROOT"
CONFIG="release" CONFIG="release"
APP="GrammaGem.app" APP="GrammarGem.app"
DIST="dist" DIST="dist"
echo "==> Building Harper static lib (universal)" echo "==> Building Harper static lib (universal)"
@ -24,7 +24,7 @@ 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"
@ -52,8 +52,8 @@ SPARKLE_FW="$(find .build -path '*Sparkle.xcframework/macos-arm64*/Sparkle.frame
mkdir -p "${DIST}/${APP}/Contents/Frameworks" mkdir -p "${DIST}/${APP}/Contents/Frameworks"
cp -R "${SPARKLE_FW}" "${DIST}/${APP}/Contents/Frameworks/" cp -R "${SPARKLE_FW}" "${DIST}/${APP}/Contents/Frameworks/"
install_name_tool -add_rpath "@executable_path/../Frameworks" \ install_name_tool -add_rpath "@executable_path/../Frameworks" \
"${DIST}/${APP}/Contents/MacOS/GrammaGem" 2>/dev/null || true "${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,11 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Cut a GrammaGem release and publish the Sparkle appcast (hosted on grammagem.app). # Cut a GrammarGem release and publish the Sparkle appcast (hosted on grammargem.com).
# #
# ./scripts/release.sh <version> e.g. ./scripts/release.sh 0.2.0 # ./scripts/release.sh <version> e.g. ./scripts/release.sh 0.2.0
# #
# Steps: stamp version -> build -> sign inside-out (incl. Sparkle) -> notarize + # Steps: stamp version -> build -> sign inside-out (incl. Sparkle) -> notarize +
# staple -> zip -> generate the EdDSA-signed appcast.xml. Then upload the .zip and # staple -> zip -> generate the EdDSA-signed appcast.xml. Then upload the .zip and
# appcast.xml to grammagem.app so every installed app shows "Update available". # appcast.xml to grammargem.com so every installed app shows "Update available".
# #
# Notarization needs ONE of: # Notarization needs ONE of:
# NOTARY_PROFILE (xcrun notarytool store-credentials) # NOTARY_PROFILE (xcrun notarytool store-credentials)
@ -17,10 +17,10 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT" cd "$ROOT"
VERSION="${1:?usage: release.sh <version> e.g. release.sh 0.2.0}" VERSION="${1:?usage: release.sh <version> e.g. release.sh 0.2.0}"
APP="dist/GrammaGem.app" APP="dist/GrammarGem.app"
RELDIR="dist/releases" RELDIR="dist/releases"
ZIP="${RELDIR}/GrammaGem-${VERSION}.zip" ZIP="${RELDIR}/GrammarGem-${VERSION}.zip"
DL_PREFIX="${DL_PREFIX:-https://grammagem.app/releases/}" DL_PREFIX="${DL_PREFIX:-https://www.grammargem.com/releases/}"
PB=/usr/libexec/PlistBuddy PB=/usr/libexec/PlistBuddy
# 1. Stamp the marketing version + bump the monotonic build number. # 1. Stamp the marketing version + bump the monotonic build number.
@ -61,11 +61,11 @@ GA="$(find .build/artifacts -name generate_appcast -type f | head -1)"
"$GA" --download-url-prefix "$DL_PREFIX" "$RELDIR" "$GA" --download-url-prefix "$DL_PREFIX" "$RELDIR"
echo echo
echo "✓ Release ${VERSION} built. Upload these to grammagem.app:" echo "✓ Release ${VERSION} built. Upload these to grammargem.com:"
echo " ${ZIP}" echo " ${ZIP}"
echo " -> ${DL_PREFIX}GrammaGem-${VERSION}.zip" echo " -> ${DL_PREFIX}GrammarGem-${VERSION}.zip"
echo " ${RELDIR}/appcast.xml" echo " ${RELDIR}/appcast.xml"
echo " -> https://grammagem.app/appcast.xml (matches Info.plist SUFeedURL)" echo " -> https://www.grammargem.com/appcast.xml (matches Info.plist SUFeedURL)"
echo echo
echo " Once uploaded, installed apps show an 'Update available' button within a day" echo " Once uploaded, installed apps show an 'Update available' button within a day"
echo " (or immediately via menu bar -> Check for Updates...)." echo " (or immediately via menu bar -> Check for Updates...)."

View File

@ -1,17 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Code-sign GrammaGem.app with Developer ID + Hardened Runtime, signing the # Code-sign GrammarGem.app with Developer ID + Hardened Runtime, signing the
# embedded Sparkle.framework's nested helpers INSIDE-OUT first (required for a # embedded Sparkle.framework's nested helpers INSIDE-OUT first (required for a
# valid, notarizable signature). # valid, notarizable signature).
# #
# Usage: ./scripts/sign-app.sh [path/to/GrammaGem.app] # Usage: ./scripts/sign-app.sh [path/to/GrammarGem.app]
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" 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)}"
ENTITLEMENTS="AppSupport/GrammaGem.entitlements" ENTITLEMENTS="AppSupport/GrammarGem.entitlements"
APP="${1:-dist/GrammaGem.app}" APP="${1:-dist/GrammarGem.app}"
sign() { codesign --force --timestamp --options runtime --sign "$DEV_ID" "$@"; } sign() { codesign --force --timestamp --options runtime --sign "$DEV_ID" "$@"; }

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,9 +15,9 @@ 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