mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
Merge pull request #1 from BigMarc/harper-engine-and-ax-fix
Harper engine and ax fix
This commit is contained in:
commit
5af4e65bfe
8
.gitignore
vendored
8
.gitignore
vendored
@ -3,3 +3,11 @@ dist/
|
||||
*.xcodeproj/xcuserdata/
|
||||
.DS_Store
|
||||
.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/
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<?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
|
||||
it in a .app bundle with this Info.plist (see scripts/build.sh) or, more
|
||||
@ -18,13 +18,13 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>GrammaGem</string>
|
||||
<string>GrammarGem</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>GrammaGem</string>
|
||||
<string>GrammarGem</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.grammagem.app</string>
|
||||
<string>com.grammargem.app</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>GrammaGem</string>
|
||||
<string>GrammarGem</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIconName</key>
|
||||
@ -43,5 +43,19 @@
|
||||
<string>© 2026 FounderGem LLC. Built in public.</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<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>
|
||||
</plist>
|
||||
|
||||
BIN
AppSupport/mlx.metallib
Normal file
BIN
AppSupport/mlx.metallib
Normal file
Binary file not shown.
78
Package.resolved
Normal file
78
Package.resolved
Normal 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
|
||||
}
|
||||
@ -1,37 +1,58 @@
|
||||
// swift-tools-version:5.10
|
||||
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
|
||||
// the box (`swift build`). The heavyweight third-party pieces from the spec —
|
||||
// Harper (Rust grammar core), MLX (local LLM), KeyboardShortcuts, Sparkle — are
|
||||
// abstracted behind protocols with working stub implementations and clearly
|
||||
// marked `TODO(real-integration)` seams. See README.md for wiring the real deps.
|
||||
// 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
|
||||
// `harper-ffi/build.sh`; `scripts/build.sh` does this automatically.
|
||||
//
|
||||
// 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(
|
||||
name: "GrammaGem",
|
||||
name: "GrammarGem",
|
||||
platforms: [
|
||||
.macOS(.v14) // Apple Silicon, macOS 14+ (per the product spec)
|
||||
],
|
||||
products: [
|
||||
.executable(name: "GrammaGem", targets: ["GrammaGem"])
|
||||
.executable(name: "GrammarGem", targets: ["GrammarGem"])
|
||||
],
|
||||
dependencies: [
|
||||
// TODO(real-integration): add when wiring real engines / distribution.
|
||||
// .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.0.0"),
|
||||
// .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"),
|
||||
// .package(url: "https://github.com/ml-explore/mlx-swift", from: "0.18.0"),
|
||||
// The Harper grammar core is bundled via a Rust static lib + C-FFI; see harper-ffi/.
|
||||
// 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").
|
||||
.package(url: "https://github.com/ml-explore/mlx-swift-examples.git", exact: "2.25.9"),
|
||||
// Secure auto-updates (EdDSA-signed appcast served from grammargem.com).
|
||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"),
|
||||
],
|
||||
targets: [
|
||||
// C shim exposing libharper_ffi's C ABI (harper-ffi/include/harper.h) to Swift.
|
||||
.target(name: "CHarper", path: "Sources/CHarper"),
|
||||
.executableTarget(
|
||||
name: "GrammaGem",
|
||||
path: "Sources/GrammaGem"
|
||||
name: "GrammarGem",
|
||||
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(
|
||||
name: "GrammaGemTests",
|
||||
dependencies: ["GrammaGem"],
|
||||
path: "Tests/GrammaGemTests"
|
||||
name: "GrammarGemTests",
|
||||
dependencies: ["GrammarGem"],
|
||||
path: "Tests/GrammarGemTests"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
18
README.md
18
README.md
@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
# ✒️ GrammaGem
|
||||
# ✒️ GrammarGem
|
||||
|
||||
### Write it once. Get it right.
|
||||
|
||||
@ -12,7 +12,7 @@ Fix grammar, sharpen tone, and rewrite anything — in every app — for one pay
|
||||

|
||||

|
||||
|
||||
[**⬇️ 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>
|
||||
|
||||
@ -20,7 +20,7 @@ Fix grammar, sharpen tone, and rewrite anything — in every app — for one pay
|
||||
|
||||
## 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**.
|
||||
|
||||
@ -30,7 +30,7 @@ Select text in any app, press one hotkey, and it’s polished in place. No copy-
|
||||
|
||||
## Why people switch
|
||||
|
||||
| | Grammarly | **GrammaGem** |
|
||||
| | Grammarly | **GrammarGem** |
|
||||
|---|:---:|:---:|
|
||||
| **Price** | ~$144 / year, forever | **Pay once** |
|
||||
| **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.
|
||||
- ⌨️ **One global hotkey** — fix or rewrite the selected text in any app: Mail, Slack, Notion, Chrome, Messages, VS Code, and more.
|
||||
- ✨ **Ask & rewrite** — “make it shorter,” “more formal,” “translate to German” — done in place.
|
||||
- 🎚️ **Writing Modes** — Email, Post, Team Chat, Academic, Code — switch the tone instantly, or let GrammaGem pick the right one for the app you’re in.
|
||||
- 🎚️ **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.
|
||||
- 🖥️ **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">
|
||||
|
||||
[**⬇️ Get GrammaGem**](https://grammagem.app/#pricing)
|
||||
[**⬇️ Get GrammarGem**](https://www.grammargem.com/#pricing)
|
||||
|
||||
</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.
|
||||
|
||||
**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
|
||||
|
||||
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">
|
||||
|
||||
**[grammagem.app](https://grammagem.app)**
|
||||
**[grammargem.com](https://www.grammargem.com)**
|
||||
|
||||
© 2026 FounderGem LLC · 1309 Coffeen Avenue STE 1200, Sheridan, WY 82801, USA
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# Built-in Writing Modes
|
||||
|
||||
Each Mode is a named system-prompt preset for the local LLM. The canonical,
|
||||
compiled definitions live in [`Sources/GrammaGem/AI/ModeRegistry.swift`](../../Sources/GrammaGem/AI/ModeRegistry.swift);
|
||||
compiled definitions live in [`Sources/GrammarGem/AI/ModeRegistry.swift`](../../Sources/GrammarGem/AI/ModeRegistry.swift);
|
||||
these JSON files mirror them for transparency and easy editing.
|
||||
|
||||
| Mode | Tier | In public repo? |
|
||||
|
||||
37
Sources/CHarper/include/harper.h
Normal file
37
Sources/CHarper/include/harper.h
Normal 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 */
|
||||
4
Sources/CHarper/include/module.modulemap
Normal file
4
Sources/CHarper/include/module.modulemap
Normal file
@ -0,0 +1,4 @@
|
||||
module CHarper {
|
||||
header "harper.h"
|
||||
export *
|
||||
}
|
||||
4
Sources/CHarper/shim.c
Normal file
4
Sources/CHarper/shim.c
Normal 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.
|
||||
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
73
Sources/GrammarGem/AI/AIEngine.swift
Normal file
73
Sources/GrammarGem/AI/AIEngine.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
111
Sources/GrammarGem/AI/AIPromptKit.swift
Normal file
111
Sources/GrammarGem/AI/AIPromptKit.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -8,8 +8,8 @@ final class CustomModesStore: ObservableObject {
|
||||
/// bundleID -> mode id (overrides the default App-Aware mapping).
|
||||
@Published private(set) var appOverrides: [String: String]
|
||||
|
||||
private let modesKey = "GrammaGem.customModes"
|
||||
private let mapKey = "GrammaGem.appModeOverrides"
|
||||
private let modesKey = "GrammarGem.customModes"
|
||||
private let mapKey = "GrammarGem.appModeOverrides"
|
||||
private let d = UserDefaults.standard
|
||||
|
||||
init() {
|
||||
@ -25,19 +25,27 @@ final class CustomModesStore: ObservableObject {
|
||||
/// Built-in + custom modes (custom ones are always paid).
|
||||
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)
|
||||
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(
|
||||
id: "custom-\(UUID().uuidString.prefix(8))",
|
||||
name: trimmed,
|
||||
systemPrompt: prompt.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
systemPrompt: trimmedPrompt,
|
||||
isPaid: true,
|
||||
lengthCap: nil,
|
||||
autoFormat: "Custom"
|
||||
)
|
||||
customModes.append(mode)
|
||||
persistModes()
|
||||
return true
|
||||
}
|
||||
|
||||
func removeCustom(id: String) {
|
||||
149
Sources/GrammarGem/AI/MLXEngine.swift
Normal file
149
Sources/GrammarGem/AI/MLXEngine.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -14,7 +14,9 @@ final class ModelManager: ObservableObject {
|
||||
|
||||
@Published private(set) var state: State = .notDownloaded
|
||||
@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 generation = 0
|
||||
@ -23,20 +25,65 @@ final class ModelManager: ObservableObject {
|
||||
if isModelPresent(repo: selectedRepo) { state = .ready }
|
||||
}
|
||||
|
||||
var modelsDirectory: URL {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
return base.appendingPathComponent("GrammaGem/Models", isDirectory: true)
|
||||
}
|
||||
var modelsDirectory: URL { Self.modelsDirectory() }
|
||||
|
||||
func modelDir(_ repo: String) -> URL {
|
||||
modelsDirectory.appendingPathComponent(repo.replacingOccurrences(of: "/", with: "_"), isDirectory: true)
|
||||
}
|
||||
func modelDir(_ repo: String) -> URL { Self.modelDir(repo) }
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
func startDownload(repo: String? = nil) {
|
||||
let target = repo ?? selectedRepo
|
||||
@ -74,7 +121,9 @@ final class ModelManager: ObservableObject {
|
||||
try Task.checkCancellation()
|
||||
set(gen, .downloading(progress: min(1, Double(done) / Double(total))),
|
||||
"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)
|
||||
if let http = response as? HTTPURLResponse, http.statusCode != 200 {
|
||||
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.
|
||||
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)
|
||||
if let http = response as? HTTPURLResponse, http.statusCode != 200 {
|
||||
throw ModelError.http(http.statusCode, "file list")
|
||||
@ -148,11 +197,13 @@ final class ModelManager: ObservableObject {
|
||||
case empty
|
||||
case http(Int, String)
|
||||
case corrupt(String)
|
||||
case invalidURL(String)
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
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 .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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
176
Sources/GrammarGem/AI/OllamaEngine.swift
Normal file
176
Sources/GrammarGem/AI/OllamaEngine.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
@ -27,8 +27,11 @@ final class AppState: ObservableObject {
|
||||
let snippets: SnippetStore
|
||||
let exclusions: Exclusions
|
||||
let liveMonitor: LiveMonitor
|
||||
let updater: UpdaterManager
|
||||
private let capture: TextCapture
|
||||
let coordinator: TextReplacementCoordinator
|
||||
/// Loopback bridge to the browser extension (opt-in; nil unless enabled).
|
||||
private var localServer: LocalEngineServer?
|
||||
|
||||
// UI state
|
||||
@Published var lastStatus: String = "Ready"
|
||||
@ -45,7 +48,11 @@ final class AppState: ObservableObject {
|
||||
private init() {
|
||||
let engine = SystemGrammarEngine()
|
||||
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()
|
||||
model = ModelManager()
|
||||
license = LicenseManager()
|
||||
@ -61,8 +68,30 @@ final class AppState: ObservableObject {
|
||||
let excl = Exclusions()
|
||||
exclusions = excl
|
||||
liveMonitor = LiveMonitor(grammar: engine, detector: detector, exclusions: excl, capture: capture)
|
||||
updater = UpdaterManager()
|
||||
let dict = dictionary
|
||||
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).
|
||||
@ -95,10 +124,19 @@ final class AppState: ObservableObject {
|
||||
|
||||
// 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
|
||||
// download is resumable/cancelable from the AI Model screen.
|
||||
if !model.isModelPresent(repo: model.selectedRepo) {
|
||||
// download is resumable/cancelable from the AI Model screen. If a model is
|
||||
// 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()
|
||||
}
|
||||
// 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()
|
||||
appAwareEnabled = gate.appAwareEnabled
|
||||
@ -109,7 +147,77 @@ final class AppState: ObservableObject {
|
||||
}
|
||||
|
||||
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
|
||||
@ -179,19 +287,39 @@ final class AppState: ObservableObject {
|
||||
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() {
|
||||
isPaused.toggle()
|
||||
liveMonitor.setPaused(isPaused)
|
||||
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? {
|
||||
if isPaused { return "GrammaGem is paused." }
|
||||
if isPaused { return "GrammarGem is paused." }
|
||||
let front = detector.frontmost()
|
||||
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
|
||||
}
|
||||
@ -256,9 +384,9 @@ final class AppState: ObservableObject {
|
||||
if onboardingWindow == nil {
|
||||
let host = NSHostingController(rootView: OnboardingView().environmentObject(self))
|
||||
let win = NSWindow(contentViewController: host)
|
||||
win.title = "Welcome to GrammaGem"
|
||||
win.title = "Welcome to GrammarGem"
|
||||
win.styleMask = [.titled, .closable, .fullSizeContentView]
|
||||
win.setContentSize(NSSize(width: 540, height: 600))
|
||||
win.setContentSize(NSSize(width: 560, height: 720))
|
||||
win.isReleasedWhenClosed = false
|
||||
win.center()
|
||||
onboardingWindow = win
|
||||
@ -285,7 +413,7 @@ final class AppState: ObservableObject {
|
||||
.environmentObject(liveMonitor)
|
||||
let host = NSHostingController(rootView: root)
|
||||
let win = NSWindow(contentViewController: host)
|
||||
win.title = "GrammaGem"
|
||||
win.title = "GrammarGem"
|
||||
win.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView]
|
||||
win.setContentSize(NSSize(width: 940, height: 660))
|
||||
win.minSize = NSSize(width: 820, height: 560)
|
||||
@ -323,7 +451,7 @@ final class AppState: ObservableObject {
|
||||
if askWindow == nil {
|
||||
let host = NSHostingController(rootView: AskView().environmentObject(self))
|
||||
let win = NSWindow(contentViewController: host)
|
||||
win.title = "Ask GrammaGem"
|
||||
win.title = "Ask GrammarGem"
|
||||
win.styleMask = [.titled, .closable]
|
||||
win.setContentSize(NSSize(width: 460, height: 220))
|
||||
win.isReleasedWhenClosed = false
|
||||
@ -8,7 +8,7 @@ struct AskView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Ask GrammaGem")
|
||||
Text("Ask GrammarGem")
|
||||
.font(.headline)
|
||||
Text("Acts on the text selected in the frontmost app. Runs on-device.")
|
||||
.font(.caption)
|
||||
76
Sources/GrammarGem/App/GrammarGemApp.swift
Normal file
76
Sources/GrammarGem/App/GrammarGemApp.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,7 @@ struct GeneralView: View {
|
||||
@EnvironmentObject private var permissions: Permissions
|
||||
|
||||
var body: some View {
|
||||
DetailScaffold(title: "General", subtitle: "Make GrammaGem feel like yours.") {
|
||||
DetailScaffold(title: "General", subtitle: "Make GrammarGem feel like yours.") {
|
||||
Card {
|
||||
Toggle("Launch at login", isOn: $prefs.launchAtLogin)
|
||||
Divider()
|
||||
@ -134,7 +134,7 @@ struct AboutView: View {
|
||||
.frame(width: 56, height: 56)
|
||||
.background(GG.emerald.opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("GrammaGem").font(.title2.bold())
|
||||
Text("GrammarGem").font(.title2.bold())
|
||||
Text("Version \(AppConfig.appVersion)").foregroundStyle(.secondary)
|
||||
Text("The private, on-device writing assistant for Mac.")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
@ -153,7 +153,7 @@ struct AboutView: View {
|
||||
|
||||
Card {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -30,6 +30,15 @@ struct DashboardView: View {
|
||||
|
||||
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: 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
|
||||
@ -61,6 +70,19 @@ struct DashboardView: View {
|
||||
.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
|
||||
Card {
|
||||
Text("Quick actions").font(.headline)
|
||||
@ -69,7 +91,7 @@ struct DashboardView: View {
|
||||
Label("Fix selection", systemImage: "checkmark.circle")
|
||||
}
|
||||
Button { app.showAsk() } label: {
|
||||
Label("Ask GrammaGem", systemImage: "sparkles")
|
||||
Label("Ask GrammarGem", systemImage: "sparkles")
|
||||
}
|
||||
if !license.isLicensed {
|
||||
Spacer()
|
||||
@ -12,7 +12,7 @@ struct DictionaryView: View {
|
||||
var body: some View {
|
||||
DetailScaffold(
|
||||
title: "Personal Dictionary",
|
||||
subtitle: "Words GrammaGem should never “correct” — names, brands, jargon."
|
||||
subtitle: "Words GrammarGem should never “correct” — names, brands, jargon."
|
||||
) {
|
||||
Card {
|
||||
HStack {
|
||||
@ -83,7 +83,7 @@ struct DevicesView: View {
|
||||
HStack {
|
||||
Link("Open license portal", destination: AppConfig.modelPortalURL)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -8,12 +8,12 @@ struct ExclusionsView: View {
|
||||
var body: some View {
|
||||
DetailScaffold(
|
||||
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 {
|
||||
Label("How it works", systemImage: "hand.raised")
|
||||
.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)
|
||||
}
|
||||
|
||||
@ -34,22 +34,32 @@ struct LiveCheckView: View {
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(summary).font(.headline)
|
||||
Text(monitor.activeAppName.isEmpty
|
||||
? "Checking text areas on screen"
|
||||
: "In \(monitor.activeAppName) · \(monitor.scannedCount) text area\(monitor.scannedCount == 1 ? "" : "s") checked")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button { monitor.refreshSoon() } label: { Image(systemName: "arrow.clockwise") }
|
||||
.help("Re-scan now")
|
||||
if monitor.issueCount > 0 {
|
||||
Button { app.fixAllDetected() } label: {
|
||||
Label("Fix everything", systemImage: "checkmark.circle.fill")
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 12) {
|
||||
ScoreRing(score: monitor.writingScore)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(summary).font(.headline)
|
||||
Text(contextLine).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button { monitor.refreshSoon() } label: { Image(systemName: "arrow.clockwise") }
|
||||
.help("Re-scan now")
|
||||
if monitor.issueCount > 0 {
|
||||
Button { app.fixAllDetected() } label: {
|
||||
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)
|
||||
@ -57,10 +67,16 @@ struct LiveCheckView: View {
|
||||
|
||||
private var summary: String {
|
||||
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")"
|
||||
}
|
||||
|
||||
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 {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.seal.fill").font(.system(size: 42)).foregroundStyle(GG.emerald)
|
||||
@ -112,9 +128,21 @@ private struct FieldCard: View {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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.
|
||||
private func highlighted(_ text: String, _ suggestions: [Suggestion]) -> AttributedString {
|
||||
let ns = text as NSString
|
||||
@ -167,3 +203,40 @@ private struct FieldCard: View {
|
||||
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())
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
@State private var selection: MainSection?
|
||||
|
||||
@ -17,7 +17,7 @@ struct ShortcutsView: View {
|
||||
combo: $prefs.fixHotkey)
|
||||
Divider()
|
||||
shortcutRow(
|
||||
name: "Ask GrammaGem",
|
||||
name: "Ask GrammarGem",
|
||||
detail: "Rewrite with an instruction",
|
||||
combo: $prefs.askHotkey)
|
||||
}
|
||||
@ -141,7 +141,7 @@ struct PrivacyView: View {
|
||||
}
|
||||
|
||||
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")
|
||||
Divider()
|
||||
networkRow("License activation & checks", "To enforce your device cap")
|
||||
@ -15,7 +15,7 @@ struct WritingModesView: View {
|
||||
var body: some View {
|
||||
DetailScaffold(
|
||||
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
|
||||
customCard
|
||||
@ -88,7 +88,7 @@ struct WritingModesView: View {
|
||||
private var appAwareCard: some View {
|
||||
Card {
|
||||
HStack { Text("App-aware switching").font(.headline); if !paid { UpgradeBadge() } }
|
||||
Text("Map an app to a Mode and GrammaGem applies it automatically when that app is in front.")
|
||||
Text("Map an app to a Mode and GrammarGem applies it automatically when that app is in front.")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
|
||||
if !customModes.appOverrides.isEmpty {
|
||||
@ -24,16 +24,22 @@ struct MenuBarLabel: View {
|
||||
struct MenuBarContent: View {
|
||||
@EnvironmentObject private var app: AppState
|
||||
@ObservedObject private var monitor: LiveMonitor
|
||||
@ObservedObject private var updater: UpdaterManager
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
|
||||
init() {
|
||||
_monitor = ObservedObject(wrappedValue: AppState.shared.liveMonitor)
|
||||
_updater = ObservedObject(wrappedValue: AppState.shared.updater)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
header
|
||||
|
||||
if let version = updater.updateAvailableVersion {
|
||||
updateBanner(version: version)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
liveStatus
|
||||
@ -43,7 +49,7 @@ struct MenuBarContent: View {
|
||||
actionRow(title: "Fix selection", shortcut: app.preferences.fixHotkey.display) {
|
||||
Task { await app.runFix() }
|
||||
}
|
||||
actionRow(title: "Ask GrammaGem…", shortcut: app.preferences.askHotkey.display) {
|
||||
actionRow(title: "Ask GrammarGem…", shortcut: app.preferences.askHotkey.display) {
|
||||
app.showAsk()
|
||||
}
|
||||
if app.gate.appAwareEnabled {
|
||||
@ -51,20 +57,22 @@ struct MenuBarContent: View {
|
||||
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()
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
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: "Page blocker…", shortcut: nil) { app.showMainWindow(select: .exclusions) }
|
||||
actionRow(title: "Check for Updates…", shortcut: nil) { app.updater.checkForUpdates() }
|
||||
|
||||
Divider()
|
||||
|
||||
usageRow
|
||||
valueRow
|
||||
|
||||
Divider()
|
||||
|
||||
@ -82,11 +90,27 @@ struct MenuBarContent: View {
|
||||
.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 {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "pencil.and.scribble").foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("GrammaGem").font(.headline)
|
||||
Text("GrammarGem").font(.headline)
|
||||
Text(app.license.tier.displayName + (app.license.isLicensed ? " · licensed" : " · free"))
|
||||
.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 {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
220
Sources/GrammarGem/App/OnboardingView.swift
Normal file
220
Sources/GrammarGem/App/OnboardingView.swift
Normal 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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -25,7 +25,7 @@ private struct GeneralSettings: View {
|
||||
Form {
|
||||
Section("Shortcuts") {
|
||||
LabeledContent("Fix selection", value: "⌘;")
|
||||
LabeledContent("Ask GrammaGem", value: "⌘'")
|
||||
LabeledContent("Ask GrammarGem", value: "⌘'")
|
||||
Text("Custom shortcuts coming via the KeyboardShortcuts recorder (see README).")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
@ -147,7 +147,7 @@ private struct DictionarySettings: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
TextField("Add a word GrammaGem should never correct", text: $newWord)
|
||||
TextField("Add a word GrammarGem should never correct", text: $newWord)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit(add)
|
||||
Button("Add", action: add).disabled(newWord.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
@ -1,15 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
/// Compile-time configuration. The Lemon Squeezy identifiers are hard-coded so
|
||||
/// the licensing layer can *hard-verify* that a key belongs to GrammaGem (and to
|
||||
/// the licensing layer can *hard-verify* that a key belongs to GrammarGem (and to
|
||||
/// the claimed tier) rather than to some other Lemon Squeezy product.
|
||||
/// TODO(real-integration): replace every `TODO_*` value with your real IDs.
|
||||
enum AppConfig {
|
||||
static let appName = "GrammaGem"
|
||||
static let bundleIdentifier = "com.foundergem.grammagem" // TODO(real-integration)
|
||||
static let supportEmail = "support@grammagem.app" // TODO(real-integration)
|
||||
static let modelPortalURL = URL(string: "https://grammagem.app/license")! // TODO
|
||||
static let websiteURL = URL(string: "https://grammagem.app")!
|
||||
static let appName = "GrammarGem"
|
||||
static let bundleIdentifier = "com.foundergem.grammargem" // TODO(real-integration)
|
||||
static let supportEmail = "support@grammargem.com" // TODO(real-integration)
|
||||
static let modelPortalURL = URL(string: "https://www.grammargem.com/license")! // TODO
|
||||
static let websiteURL = URL(string: "https://www.grammargem.com")!
|
||||
static let appVersion = "0.1.0"
|
||||
|
||||
// Legal entity (shown in About / legal).
|
||||
@ -37,6 +37,23 @@ enum AppConfig {
|
||||
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).
|
||||
enum Limits {
|
||||
static let freeDailyAIActions = 10
|
||||
@ -42,6 +42,42 @@ enum GrammarKind: String, Codable {
|
||||
case phrasing
|
||||
case repetition
|
||||
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.
|
||||
@ -72,7 +72,7 @@ final class FeatureGate: ObservableObject {
|
||||
/// Free-tier AI action counter. Persists `{date, count}` in UserDefaults and
|
||||
/// resets at local midnight (compares the stored local date string to today's).
|
||||
final class DailyCounter {
|
||||
private let key = "GrammaGem.aiActions.daily"
|
||||
private let key = "GrammarGem.aiActions.daily"
|
||||
private let defaults = UserDefaults.standard
|
||||
|
||||
private static let formatter: DateFormatter = {
|
||||
@ -19,9 +19,15 @@ protocol GrammarEngine: AnyObject {
|
||||
|
||||
extension GrammarEngine {
|
||||
/// 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 {
|
||||
let ns = NSMutableString(string: text)
|
||||
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 }
|
||||
ns.replaceCharacters(in: s.range, with: s.replacement)
|
||||
}
|
||||
83
Sources/GrammarGem/Grammar/HarperEngine.swift
Normal file
83
Sources/GrammarGem/Grammar/HarperEngine.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,13 @@
|
||||
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
|
||||
/// entries on free, unlimited on paid (enforced by `FeatureGate`, not here).
|
||||
@MainActor
|
||||
final class PersonalDictionary: ObservableObject {
|
||||
@Published private(set) var entries: [String]
|
||||
|
||||
private let key = "GrammaGem.personalDictionary"
|
||||
private let key = "GrammarGem.personalDictionary"
|
||||
private let defaults = UserDefaults.standard
|
||||
|
||||
init() {
|
||||
90
Sources/GrammarGem/Grammar/SystemGrammarEngine.swift
Normal file
90
Sources/GrammarGem/Grammar/SystemGrammarEngine.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
20
Sources/GrammarGem/Grammar/WritingScore.swift
Normal file
20
Sources/GrammarGem/Grammar/WritingScore.swift
Normal 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())))
|
||||
}
|
||||
}
|
||||
@ -38,7 +38,7 @@ enum DeviceFingerprint {
|
||||
}
|
||||
|
||||
private static func fallbackID() -> String {
|
||||
let key = "GrammaGem.fallbackDeviceID"
|
||||
let key = "GrammarGem.fallbackDeviceID"
|
||||
if let existing = UserDefaults.standard.string(forKey: key) { return existing }
|
||||
let id = UUID().uuidString
|
||||
UserDefaults.standard.set(id, forKey: key)
|
||||
@ -2,7 +2,7 @@ import Foundation
|
||||
|
||||
/// Talks to the **Lemon Squeezy License API** directly from the app — no server
|
||||
/// of ours, no database, no per-user cost (spec §6). Exposes activate / validate
|
||||
/// / deactivate and hard-verifies that a key belongs to GrammaGem + the claimed tier.
|
||||
/// / deactivate and hard-verifies that a key belongs to GrammarGem + the claimed tier.
|
||||
final class LemonSqueezyClient {
|
||||
private let session: URLSession
|
||||
private let base = AppConfig.LemonSqueezy.apiBase
|
||||
@ -54,20 +54,28 @@ final class LemonSqueezyClient {
|
||||
// MARK: - Hard verification (spec §6)
|
||||
|
||||
/// Confirm the key is for OUR store/product, then resolve which tier its
|
||||
/// variant corresponds to. Prevents a key from another LS product unlocking GrammaGem.
|
||||
/// variant corresponds to. Prevents a key from another LS product unlocking GrammarGem.
|
||||
private func verifyAndResolveTier(meta: LSMeta?, licenseKey: LSLicenseKey?) throws -> Tier {
|
||||
guard let meta else { throw LicenseError.wrongProduct }
|
||||
|
||||
// Store + product check. If AppConfig still holds placeholder (non-numeric)
|
||||
// IDs, we log and skip the check so the stub flow runs — TODO(real-integration):
|
||||
// once real numeric IDs are set, this becomes a hard gate.
|
||||
// Store + product check. With real numeric IDs configured this is a hard
|
||||
// gate that prevents a key from another Lemon Squeezy product unlocking
|
||||
// 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),
|
||||
let expectedProduct = Int(AppConfig.LemonSqueezy.expectedProductID) {
|
||||
guard meta.store_id == expectedStore, meta.product_id == expectedProduct else {
|
||||
throw LicenseError.wrongProduct
|
||||
}
|
||||
} else {
|
||||
Log.licensing.warning("Skipping store/product hard-verify — placeholder IDs in AppConfig.")
|
||||
#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).
|
||||
@ -62,7 +62,7 @@ struct LSLicenseKey: Codable {
|
||||
}
|
||||
|
||||
/// `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 {
|
||||
let store_id: Int?
|
||||
let product_id: Int?
|
||||
154
Sources/GrammarGem/Stats/UsageStats.swift
Normal file
154
Sources/GrammarGem/Stats/UsageStats.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
76
Sources/GrammarGem/System/AXSupport.swift
Normal file
76
Sources/GrammarGem/System/AXSupport.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
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.
|
||||
/// When the frontmost context is excluded, live monitoring and the hotkeys are
|
||||
/// suppressed.
|
||||
@ -10,8 +10,8 @@ final class Exclusions: ObservableObject {
|
||||
@Published private(set) var blockedApps: [String] // bundle identifiers
|
||||
@Published private(set) var blockedDomains: [String] // hostnames, e.g. "chase.com"
|
||||
|
||||
private let appsKey = "GrammaGem.blockedApps"
|
||||
private let domainsKey = "GrammaGem.blockedDomains"
|
||||
private let appsKey = "GrammarGem.blockedApps"
|
||||
private let domainsKey = "GrammarGem.blockedDomains"
|
||||
private let d = UserDefaults.standard
|
||||
|
||||
/// 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).
|
||||
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 {
|
||||
if let bundleID, blockedApps.contains(bundleID) { return true }
|
||||
if let host = domain?.lowercased(), !host.isEmpty {
|
||||
@ -19,18 +19,40 @@ struct DetectedField: Identifiable {
|
||||
/// 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
|
||||
/// 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.
|
||||
@MainActor
|
||||
final class LiveMonitor: ObservableObject {
|
||||
@Published private(set) var fields: [DetectedField] = []
|
||||
@Published private(set) var scannedCount = 0
|
||||
@Published private(set) var scannedWordCount = 0
|
||||
@Published private(set) var activeAppName = ""
|
||||
@Published private(set) var running = false
|
||||
|
||||
/// Total number of issues across all detected text areas.
|
||||
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 detector: AppDetector
|
||||
private let exclusions: Exclusions
|
||||
@ -88,6 +110,36 @@ final class LiveMonitor: ObservableObject {
|
||||
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
|
||||
/// keystrokes entered since the scan are preserved.
|
||||
private func applyCorrection(to field: DetectedField) -> Int {
|
||||
@ -127,7 +179,7 @@ final class LiveMonitor: ObservableObject {
|
||||
if scanning && !force { return } // don't pile up scans
|
||||
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 }
|
||||
activeAppName = app.localizedName ?? ""
|
||||
|
||||
@ -156,6 +208,11 @@ final class LiveMonitor: ObservableObject {
|
||||
guard generation == scanGeneration else { return } // superseded by a newer scan
|
||||
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
|
||||
// same-text fields still updates the focused indicator.
|
||||
var hasher = Hasher()
|
||||
@ -179,16 +236,17 @@ final class LiveMonitor: ObservableObject {
|
||||
text: f.text, suggestions: suggestions, element: f.element, isFocused: f.isFocused))
|
||||
}
|
||||
detected.sort { ($0.isFocused ? 0 : 1) < ($1.isFocused ? 0 : 1) }
|
||||
fields = detected
|
||||
fields = filterDismissed(detected)
|
||||
}
|
||||
|
||||
private nonisolated static func systemFocusedElement() -> AXUIElement? {
|
||||
AX.copyElement(AXUIElementCreateSystemWide(), kAXFocusedUIElementAttribute)
|
||||
AX.focusedElement()
|
||||
}
|
||||
|
||||
private func clear() {
|
||||
if !fields.isEmpty { fields = [] }
|
||||
scannedCount = 0
|
||||
scannedWordCount = 0
|
||||
lastHash = 0
|
||||
}
|
||||
}
|
||||
311
Sources/GrammarGem/System/LocalEngineServer.swift
Normal file
311
Sources/GrammarGem/System/LocalEngineServer.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@ import Foundation
|
||||
import AppKit
|
||||
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
|
||||
/// clipboard fallback, **Input Monitoring** may also be prompted by the system.
|
||||
@MainActor
|
||||
@ -13,7 +13,7 @@ struct Snippet: Codable, Identifiable, Equatable {
|
||||
final class SnippetStore: ObservableObject {
|
||||
@Published private(set) var snippets: [Snippet]
|
||||
|
||||
private let key = "GrammaGem.snippets"
|
||||
private let key = "GrammarGem.snippets"
|
||||
private let d = UserDefaults.standard
|
||||
|
||||
init() {
|
||||
@ -4,7 +4,7 @@ import ApplicationServices
|
||||
import Carbon.HIToolbox
|
||||
|
||||
/// Reads the user's selected text from the frontmost app and writes a corrected
|
||||
/// value back — the mechanism that makes GrammaGem work *everywhere*.
|
||||
/// value back — the mechanism that makes GrammarGem work *everywhere*.
|
||||
///
|
||||
/// Two paths, mirroring the spec (§4):
|
||||
/// 1. **Accessibility API** (preferred): read/write `kAXSelectedTextAttribute`
|
||||
@ -24,10 +24,12 @@ final class TextCapture {
|
||||
enum CaptureError: LocalizedError {
|
||||
case noSelection
|
||||
case notPermitted
|
||||
case secureField
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noSelection: return "Select some text first, then press the hotkey."
|
||||
case .notPermitted: return "GrammaGem needs Accessibility permission to read and replace text."
|
||||
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
|
||||
|
||||
/// 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 {
|
||||
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
|
||||
}
|
||||
if let clip = try captureViaClipboard() {
|
||||
@ -51,11 +60,8 @@ final class TextCapture {
|
||||
throw CaptureError.noSelection
|
||||
}
|
||||
|
||||
private func captureViaAccessibility() -> Capture? {
|
||||
guard AXIsProcessTrusted() else { return nil }
|
||||
let systemWide = AXUIElementCreateSystemWide()
|
||||
|
||||
guard let focused = AX.copyElement(systemWide, kAXFocusedUIElementAttribute) else { return nil }
|
||||
private func captureViaAccessibility(_ focused: AXUIElement?) -> Capture? {
|
||||
guard let focused else { return nil }
|
||||
|
||||
var valueRef: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(
|
||||
@ -93,8 +99,8 @@ final class TextCapture {
|
||||
/// The full text value of the focused element (not just the selection).
|
||||
func focusedFieldText() -> (text: String, element: AXUIElement)? {
|
||||
guard AXIsProcessTrusted() else { return nil }
|
||||
let systemWide = AXUIElementCreateSystemWide()
|
||||
guard let focused = AX.copyElement(systemWide, kAXFocusedUIElementAttribute) else { return nil }
|
||||
guard let focused = AX.focusedElement() 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
|
||||
// "nothing selected" failures on sliders/steppers/custom controls.
|
||||
@ -15,7 +15,7 @@ struct ScannedField {
|
||||
|
||||
/// Walks the Accessibility tree of the frontmost window and collects every
|
||||
/// 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
|
||||
/// 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
|
||||
|| role == kAXTextAreaRole 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 {
|
||||
let label = AX.copyString(el, kAXTitleAttribute)
|
||||
?? AX.copyString(el, kAXDescriptionAttribute)
|
||||
@ -12,14 +12,19 @@ final class TextReplacementCoordinator {
|
||||
private let ai: AIEngine
|
||||
private let gate: FeatureGate
|
||||
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,
|
||||
gate: FeatureGate, detector: AppDetector) {
|
||||
gate: FeatureGate, detector: AppDetector,
|
||||
protectedTerms: @escaping () -> [String] = { [] }) {
|
||||
self.capture = capture
|
||||
self.grammar = grammar
|
||||
self.ai = ai
|
||||
self.gate = gate
|
||||
self.detector = detector
|
||||
self.protectedTerms = protectedTerms
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let result = try await ai.run(action, on: cap.text)
|
||||
let result = try await ai.run(action, on: cap.text, protectedTerms: protectedTerms())
|
||||
gate.recordAIActionUsed()
|
||||
let ok = await replaceOffMain(result, cap)
|
||||
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 {
|
||||
let result = try await ai.run(action, on: cap.text)
|
||||
let result = try await ai.run(action, on: cap.text, protectedTerms: protectedTerms())
|
||||
gate.recordAIActionUsed()
|
||||
let ok = await replaceOffMain(result, cap)
|
||||
return ok ? .replaced(result) : .failed("Couldn't write the result back.")
|
||||
81
Sources/GrammarGem/System/UpdaterManager.swift
Normal file
81
Sources/GrammarGem/System/UpdaterManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
222
Tests/GrammarGemTests/GrammarGemTests.swift
Normal file
222
Tests/GrammarGemTests/GrammarGemTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
204
docs/grammarly-value-plan.md
Normal file
204
docs/grammarly-value-plan.md
Normal 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
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
21
harper-ffi/Cargo.toml
Normal 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
|
||||
@ -5,7 +5,7 @@ A thin Rust static library that exposes the **Harper** grammar core
|
||||
integration (spec §1, §2, §8).
|
||||
|
||||
The Swift side talks to this through the `GrammarEngine` protocol; today
|
||||
[`HarperEngine.swift`](../Sources/GrammaGem/Grammar/HarperEngine.swift) ships a
|
||||
[`HarperEngine.swift`](../Sources/GrammarGem/Grammar/HarperEngine.swift) ships a
|
||||
small pure-Swift rule stub so the system loop + tests run with zero native deps.
|
||||
|
||||
## Planned shape
|
||||
|
||||
26
harper-ffi/build.sh
Executable file
26
harper-ffi/build.sh
Executable 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"
|
||||
56
harper-ffi/include/harper.h
Normal file
56
harper-ffi/include/harper.h
Normal 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
170
harper-ffi/src/lib.rs
Normal 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
25
scripts/build-metallib.sh
Executable 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))"
|
||||
@ -1,5 +1,6 @@
|
||||
#!/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)
|
||||
set -euo pipefail
|
||||
|
||||
@ -7,21 +8,52 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
CONFIG="release"
|
||||
APP="GrammaGem.app"
|
||||
APP="GrammarGem.app"
|
||||
DIST="dist"
|
||||
|
||||
echo "==> Compiling universal (arm64 + x86_64, ${CONFIG})"
|
||||
swift build -c "${CONFIG}" --arch arm64 --arch x86_64
|
||||
BIN="$(swift build -c "${CONFIG}" --arch arm64 --arch x86_64 --show-bin-path)"
|
||||
echo "==> Building Harper static lib (universal)"
|
||||
chmod +x harper-ffi/build.sh
|
||||
./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}"
|
||||
rm -rf "${DIST}/${APP}"
|
||||
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/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}"
|
||||
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)"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#!/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
|
||||
// Then: iconutil -c icns AppIcon.iconset -o AppSupport/AppIcon.icns
|
||||
import AppKit
|
||||
|
||||
@ -1,32 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
# Package a signed build for distribution and update the Sparkle appcast hosted
|
||||
# on GitHub Releases (zero hosting cost). Run after sign-notarize.sh.
|
||||
# Cut a GrammarGem release and publish the Sparkle appcast (hosted on grammargem.com).
|
||||
#
|
||||
# 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
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
APP="$ROOT/dist/GrammaGem.app"
|
||||
VERSION="${1:?Usage: release.sh <version> e.g. 0.1.0}"
|
||||
DMG="$ROOT/dist/GrammaGem-$VERSION.dmg"
|
||||
cd "$ROOT"
|
||||
|
||||
echo "▸ Creating DMG for $VERSION…"
|
||||
# hdiutil create -volname "GrammaGem" -srcfolder "$APP" -ov -format UDZO "$DMG" # TODO: enable
|
||||
echo " (DMG creation stubbed — uncomment hdiutil line)"
|
||||
VERSION="${1:?usage: release.sh <version> e.g. release.sh 0.2.0}"
|
||||
APP="dist/GrammarGem.app"
|
||||
RELDIR="dist/releases"
|
||||
ZIP="${RELDIR}/GrammarGem-${VERSION}.zip"
|
||||
DL_PREFIX="${DL_PREFIX:-https://www.grammargem.com/releases/}"
|
||||
PB=/usr/libexec/PlistBuddy
|
||||
|
||||
cat <<'NOTE'
|
||||
▸ Sparkle appcast (TODO):
|
||||
1. Sign the update: ./bin/sign_update dist/GrammaGem-<ver>.dmg
|
||||
(Sparkle's sign_update tool, using your EdDSA private key)
|
||||
2. Add an <item> to appcast.xml with the version, URL (GitHub Releases asset),
|
||||
length, and sparkle:edSignature.
|
||||
3. Upload the DMG + appcast.xml to a GitHub Release.
|
||||
The app's SUFeedURL points at the raw appcast.xml on GitHub — $0 hosting.
|
||||
# 1. Stamp the marketing version + bump the monotonic build number.
|
||||
CUR_BUILD="$($PB -c 'Print :CFBundleVersion' AppSupport/Info.plist)"
|
||||
NEW_BUILD=$((CUR_BUILD + 1))
|
||||
$PB -c "Set :CFBundleShortVersionString ${VERSION}" AppSupport/Info.plist
|
||||
$PB -c "Set :CFBundleVersion ${NEW_BUILD}" AppSupport/Info.plist
|
||||
echo "==> Releasing ${VERSION} (build ${NEW_BUILD})"
|
||||
|
||||
▸ Distribution touchpoints (all $0 marginal cost):
|
||||
- Model weights: Hugging Face CDN
|
||||
- Updates: GitHub Releases + Sparkle
|
||||
- Licensing: Lemon Squeezy License API (% per sale only)
|
||||
NOTE
|
||||
# 2. Build + sign (inside-out so the embedded Sparkle.framework is valid).
|
||||
./scripts/build.sh
|
||||
./scripts/sign-app.sh "$APP"
|
||||
|
||||
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
35
scripts/sign-app.sh
Executable 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"
|
||||
@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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.
|
||||
#
|
||||
# Credentials are read from the environment or a stored keychain profile — never
|
||||
@ -15,19 +15,15 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
DEV_ID="${DEV_ID:-Developer ID Application: Marc Schultheiss (D5KT5B9Z9M)}"
|
||||
APP="dist/GrammaGem.app"
|
||||
ZIP="dist/GrammaGem.zip"
|
||||
ENTITLEMENTS="AppSupport/GrammaGem.entitlements"
|
||||
APP="dist/GrammarGem.app"
|
||||
ZIP="dist/GrammarGem.zip"
|
||||
ENTITLEMENTS="AppSupport/GrammarGem.entitlements"
|
||||
|
||||
echo "==> Building universal bundle"
|
||||
./scripts/build.sh >/dev/null
|
||||
|
||||
echo "==> Codesigning (Hardened Runtime)"
|
||||
codesign --force --timestamp --options runtime \
|
||||
--entitlements "$ENTITLEMENTS" \
|
||||
--sign "$DEV_ID" \
|
||||
"$APP"
|
||||
codesign --verify --strict --verbose=2 "$APP"
|
||||
echo "==> Codesigning (Hardened Runtime, inside-out incl. Sparkle)"
|
||||
DEV_ID="$DEV_ID" ./scripts/sign-app.sh "$APP"
|
||||
|
||||
echo "==> Zipping"
|
||||
ditto -c -k --keepParent "$APP" "$ZIP"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user