mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
GrammaGem — open-source, on-device macOS writing assistant
Native SwiftUI menu-bar app: global hotkey → capture selected text (AX API / clipboard fallback) → grammar (Harper) / local AI (MLX) → replace in place. Free/paid entitlement gate + Lemon Squeezy device-cap licensing, $0 marginal cost per user. Heavyweight engines behind protocol stubs so it builds out of the box. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f5faa27f88
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
.build/
|
||||
dist/
|
||||
*.xcodeproj/xcuserdata/
|
||||
.DS_Store
|
||||
.swiftpm/
|
||||
21
AppSupport/GrammaGem.entitlements
Normal file
21
AppSupport/GrammaGem.entitlements
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Entitlements for codesigning the notarized release.
|
||||
|
||||
IMPORTANT: a system-wide tool that reads/replaces text in OTHER apps (via the
|
||||
Accessibility API + synthetic keystrokes) is INCOMPATIBLE with the App Sandbox,
|
||||
so the sandbox is intentionally OFF. Notarization still requires the Hardened
|
||||
Runtime (enabled at signing time with `--options runtime`, not via this file).
|
||||
|
||||
The network client entitlement is listed for completeness (model download +
|
||||
license API). No server-side anything; no sensitive entitlements beyond this.
|
||||
-->
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<false/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
43
AppSupport/Info.plist
Normal file
43
AppSupport/Info.plist
Normal file
@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Info.plist for the packaged GrammaGem.app bundle.
|
||||
|
||||
`swift build` produces a bare executable; to ship a real menu-bar app you wrap
|
||||
it in a .app bundle with this Info.plist (see scripts/build.sh) or, more
|
||||
commonly, build from an Xcode app target that uses this file.
|
||||
|
||||
LSUIElement = menu-bar app with no Dock icon. (The code also calls
|
||||
NSApp.setActivationPolicy(.accessory) so it behaves correctly even when run
|
||||
as a bare SwiftPM executable.)
|
||||
|
||||
NOTE: Accessibility permission is requested at runtime via AXIsProcessTrusted
|
||||
and has no Info.plist usage-string key. Input Monitoring (for synthetic ⌘C/⌘V
|
||||
in the clipboard fallback) is likewise system-managed.
|
||||
-->
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>GrammaGem</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>GrammaGem</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.grammagem.app</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>GrammaGem</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© 2026 GrammaGem. Built in public.</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
</dict>
|
||||
</plist>
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 GrammaGem
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
37
Package.swift
Normal file
37
Package.swift
Normal file
@ -0,0 +1,37 @@
|
||||
// swift-tools-version:5.10
|
||||
import PackageDescription
|
||||
|
||||
// GrammaGem — native macOS menu-bar writing assistant.
|
||||
//
|
||||
// This package builds with ONLY Apple frameworks so it compiles cleanly out of
|
||||
// the box (`swift build`). The heavyweight third-party pieces from the spec —
|
||||
// Harper (Rust grammar core), MLX (local LLM), KeyboardShortcuts, Sparkle — are
|
||||
// abstracted behind protocols with working stub implementations and clearly
|
||||
// marked `TODO(real-integration)` seams. See README.md for wiring the real deps.
|
||||
let package = Package(
|
||||
name: "GrammaGem",
|
||||
platforms: [
|
||||
.macOS(.v14) // Apple Silicon, macOS 14+ (per the product spec)
|
||||
],
|
||||
products: [
|
||||
.executable(name: "GrammaGem", targets: ["GrammaGem"])
|
||||
],
|
||||
dependencies: [
|
||||
// TODO(real-integration): add when wiring real engines / distribution.
|
||||
// .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.0.0"),
|
||||
// .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"),
|
||||
// .package(url: "https://github.com/ml-explore/mlx-swift", from: "0.18.0"),
|
||||
// The Harper grammar core is bundled via a Rust static lib + C-FFI; see harper-ffi/.
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "GrammaGem",
|
||||
path: "Sources/GrammaGem"
|
||||
),
|
||||
.testTarget(
|
||||
name: "GrammaGemTests",
|
||||
dependencies: ["GrammaGem"],
|
||||
path: "Tests/GrammaGemTests"
|
||||
),
|
||||
]
|
||||
)
|
||||
125
README.md
Normal file
125
README.md
Normal file
@ -0,0 +1,125 @@
|
||||
# GrammaGem (macOS app)
|
||||
|
||||
A private, **on-device** writing assistant for macOS — a one-time-purchase
|
||||
replacement for Grammarly. Fix grammar, sharpen tone, and rewrite anything in
|
||||
**any app** via a global hotkey. Your words never leave your Mac.
|
||||
|
||||
Built in public. The grammar core is open-source; the repo is permissively
|
||||
licensed. We sell the **signed, notarized, auto-updating build + a multi-device
|
||||
license + support** — not code secrecy.
|
||||
|
||||
---
|
||||
|
||||
## Architecture (two layers)
|
||||
|
||||
```
|
||||
GrammaGem (native menu-bar app, Swift/SwiftUI)
|
||||
├─ System integration global hotkey → capture selected text (AX API /
|
||||
│ clipboard fallback) → route to engine → replace in place
|
||||
├─ Layer 1: GRAMMAR Harper (Rust, Apache-2.0) — spelling/grammar/punctuation,
|
||||
│ deterministic, <10ms, offline. Powers live underlines. FREE.
|
||||
├─ Layer 2: AI MLX (Apple Silicon) + a small instruct model — rewrite,
|
||||
│ tone, "Ask", translate, Mode rewriting. Closes the gap. PAID.
|
||||
└─ Entitlements + Licensing free/paid gate · device-cap activation (Lemon Squeezy)
|
||||
```
|
||||
|
||||
Network is touched **only** for: first-run model download (Hugging Face),
|
||||
license activate/validate (Lemon Squeezy), and update checks (Sparkle/GitHub
|
||||
Releases). **Writing never requires internet. No telemetry.**
|
||||
|
||||
## Repo layout
|
||||
|
||||
```
|
||||
mac/
|
||||
├─ Package.swift SwiftPM executable (Apple-frameworks only → builds out of the box)
|
||||
├─ Sources/GrammaGem/
|
||||
│ ├─ App/ @main app, menu bar, onboarding, settings, Ask popover, AppState
|
||||
│ ├─ System/ hotkey (Carbon), AX capture/replace + clipboard fallback, app detection, permissions
|
||||
│ ├─ Grammar/ GrammarEngine protocol + Harper stub + personal dictionary
|
||||
│ ├─ AI/ AIEngine protocol + MLX stub + model manager + Writing Modes
|
||||
│ ├─ Licensing/ Lemon Squeezy client, device fingerprint, Keychain, license manager
|
||||
│ ├─ Entitlements/ free/paid gate + daily AI counter
|
||||
│ └─ Core/ config, models, logging
|
||||
├─ Tests/GrammaGemTests/ unit tests (entitlements, Harper rules, fingerprint, modes)
|
||||
├─ Resources/Modes/ Mode prompt presets (paid ones tuned in release only)
|
||||
├─ AppSupport/ Info.plist (LSUIElement) + entitlements
|
||||
├─ scripts/ build → sign/notarize → release(Sparkle/GitHub)
|
||||
└─ harper-ffi/ Rust↔Swift FFI for the real Harper core (placeholder)
|
||||
```
|
||||
|
||||
## Build & run
|
||||
|
||||
```bash
|
||||
cd mac
|
||||
swift build # compiles the app (Apple frameworks only)
|
||||
swift test # runs the unit tests
|
||||
swift run GrammaGem # runs as a menu-bar (accessory) app
|
||||
|
||||
# Package a .app bundle you can double-click:
|
||||
./scripts/build.sh # → dist/GrammaGem.app
|
||||
```
|
||||
|
||||
On first launch, grant **Accessibility** when prompted (System Settings →
|
||||
Privacy & Security → Accessibility). Without it the app degrades gracefully to a
|
||||
manual paste-in window. Default hotkeys: **⌘;** fix selection, **⌘'** Ask.
|
||||
|
||||
> Prefer Xcode? Open `Package.swift` directly in Xcode 26 (File → Open). For a
|
||||
> production app target, point it at `AppSupport/Info.plist` + entitlements.
|
||||
|
||||
## What's real vs. stubbed
|
||||
|
||||
This codebase implements the full **architecture and product logic** end-to-end,
|
||||
with the heavyweight native integrations behind clean protocol seams so it
|
||||
compiles with **zero external dependencies**. Search `TODO(real-integration)`.
|
||||
|
||||
| Area | Status |
|
||||
|------|--------|
|
||||
| System-wide capture → transform → replace loop | ✅ Real (AX + clipboard fallback, CGEvent ⌘C/⌘V, restore clipboard) |
|
||||
| Global hotkeys | ✅ Real (Carbon `RegisterEventHotKey`) |
|
||||
| Active-app detection + Mode mapping | ✅ Real (`NSWorkspace`) |
|
||||
| Permissions onboarding | ✅ Real (`AXIsProcessTrusted`, deep-links, auto-advance) |
|
||||
| Entitlements gate + daily AI counter (resets local midnight) | ✅ Real |
|
||||
| Device fingerprint (IOPlatformUUID → SHA-256) | ✅ Real (IOKit + CryptoKit) |
|
||||
| Keychain license storage | ✅ Real (Security framework) |
|
||||
| Lemon Squeezy activate/validate/deactivate + hard-verify + offline grace | ✅ Real client (needs your store/product/variant IDs) |
|
||||
| Personal dictionary (25 free / unlimited paid) | ✅ Real |
|
||||
| Menu bar UI, Settings, Ask popover | ✅ Real (SwiftUI) |
|
||||
| **Harper grammar core** | 🔌 Stub: small pure-Swift rule set; swap for the Rust FFI (`harper-ffi/`) |
|
||||
| **MLX local LLM** | 🔌 Stub: deterministic transforms; swap for `mlx-swift` + downloaded model |
|
||||
| **Model download** | 🔌 Stub: simulated progress; swap for real Hugging Face streaming |
|
||||
| KeyboardShortcuts recorder UI / Sparkle auto-update | 🔌 Documented seams (scripts + Package.swift comments) |
|
||||
|
||||
### Before shipping
|
||||
1. Set real IDs in [`Core/AppConfig.swift`](Sources/GrammaGem/Core/AppConfig.swift):
|
||||
Lemon Squeezy `expectedStoreID` / `expectedProductID` / per-tier `variantID`
|
||||
(set each variant's **activation limit** to the device cap: Solo 1 / Personal 2 / Studio 4),
|
||||
`bundleIdentifier`, `supportEmail`, model portal URL.
|
||||
2. Replace the Harper stub with the FFI (`harper-ffi/README.md`).
|
||||
3. Replace the MLX stub + model manager with `mlx-swift` and real HF download.
|
||||
4. Add `KeyboardShortcuts` + `Sparkle` SwiftPM deps (commented in `Package.swift`).
|
||||
5. Codesign + notarize via `scripts/sign-notarize.sh` (needs Apple Developer Program).
|
||||
|
||||
## Honest gaps (by design)
|
||||
|
||||
- **No plagiarism detection.** It needs a server-side corpus + live index, which
|
||||
breaks both the $0-cost rule and the privacy promise. Out of scope, not faked.
|
||||
- **The gate is intentionally soft.** Because the code is public, a technical
|
||||
user could compile a build with the gate removed — that's the VoiceInk model
|
||||
and it's fine. The product is the signed build + license + support, not secrecy.
|
||||
|
||||
## Cost model — $0 marginal cost per customer
|
||||
|
||||
| Item | Cost |
|
||||
|------|------|
|
||||
| Server / database | $0 (none exists) |
|
||||
| AI inference (on device) | $0 |
|
||||
| Model hosting (Hugging Face) | $0 |
|
||||
| Update hosting (GitHub Releases) | $0 |
|
||||
| License validation (Lemon Squeezy) | $0 (included) |
|
||||
| Apple Developer Program | $99 / year (fixed) |
|
||||
| Payment processor | ~5% + fee per sale |
|
||||
|
||||
## License
|
||||
|
||||
Permissive (MIT/Apache-2.0) to maximize trust and stars. Monetization is the
|
||||
signed binary + multi-device license, not the source.
|
||||
14
Resources/Modes/README.md
Normal file
14
Resources/Modes/README.md
Normal file
@ -0,0 +1,14 @@
|
||||
# Built-in Writing Modes
|
||||
|
||||
Each Mode is a named system-prompt preset for the local LLM. The canonical,
|
||||
compiled definitions live in [`Sources/GrammaGem/AI/ModeRegistry.swift`](../../Sources/GrammaGem/AI/ModeRegistry.swift);
|
||||
these JSON files mirror them for transparency and easy editing.
|
||||
|
||||
| Mode | Tier | In public repo? |
|
||||
|------|------|-----------------|
|
||||
| `polish` | Free | ✅ Yes — the open free core |
|
||||
| `email`, `post`, `slack`, `academic`, `code` | Paid | Baseline prompts only |
|
||||
|
||||
**Soft deterrent (spec §5):** ship the *fully-tuned* paid-mode prompts only in
|
||||
the signed release build, not in the public repository. The free `polish` mode
|
||||
is fully open. Custom user Modes (paid) are stored locally per user.
|
||||
9
Resources/Modes/email.json
Normal file
9
Resources/Modes/email.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "email",
|
||||
"name": "Email",
|
||||
"isPaid": true,
|
||||
"lengthCap": 120,
|
||||
"autoFormat": "Greeting + sign-off",
|
||||
"_note": "PAID mode. Ship the fully-tuned prompt only in the signed release, not the public repo (spec §5 soft deterrent). This is a readable baseline.",
|
||||
"systemPrompt": "Rewrite the user's text as a professional, warm email. Keep it concise and natural — never robotic. Add a brief greeting and sign-off only if appropriate. Return only the email body."
|
||||
}
|
||||
8
Resources/Modes/polish.json
Normal file
8
Resources/Modes/polish.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "polish",
|
||||
"name": "Polish",
|
||||
"isPaid": false,
|
||||
"lengthCap": null,
|
||||
"autoFormat": "Preserve original",
|
||||
"systemPrompt": "You are a careful copy editor. Lightly clean up the user's text: fix grammar, spelling, and punctuation, and improve clarity, while preserving their voice, meaning, and formatting. Return only the revised text with no commentary."
|
||||
}
|
||||
32
Sources/GrammaGem/AI/AIEngine.swift
Normal file
32
Sources/GrammaGem/AI/AIEngine.swift
Normal file
@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
|
||||
/// Layer 2 — local-LLM-powered rewriting / tone / "Ask" / translate.
|
||||
///
|
||||
/// Backed in production by **MLX** (mlx-swift) running a small instruct model
|
||||
/// downloaded once from Hugging Face. The protocol isolates the rest of the app
|
||||
/// from the runtime so the UI, gating, and tests don't depend on MLX being present.
|
||||
protocol AIEngine: AnyObject {
|
||||
/// Whether a model is loaded and ready to run locally.
|
||||
var isReady: Bool { get }
|
||||
|
||||
/// Run an action against `text` and return the rewritten result.
|
||||
/// Throws on model-not-ready or generation failure. Never performs network I/O.
|
||||
func run(_ action: AIAction, on text: String) async throws -> String
|
||||
|
||||
/// Best-effort local tone classification (used by tone-detection UI).
|
||||
func detectTone(_ text: String) async -> Tone
|
||||
}
|
||||
|
||||
enum AIEngineError: LocalizedError {
|
||||
case modelNotReady
|
||||
case generationFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .modelNotReady:
|
||||
return "The on-device model isn't ready yet. Finish the first-run download in Settings."
|
||||
case .generationFailed(let why):
|
||||
return "On-device rewrite failed: \(why)"
|
||||
}
|
||||
}
|
||||
}
|
||||
91
Sources/GrammaGem/AI/MLXEngine.swift
Normal file
91
Sources/GrammaGem/AI/MLXEngine.swift
Normal file
@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
|
||||
/// STUB standing in for the real MLX-backed local LLM.
|
||||
///
|
||||
/// TODO(real-integration): load the model via mlx-swift from
|
||||
/// `ModelManager.modelsDirectory`, build the prompt from the action's system
|
||||
/// prompt + the user's text, and stream tokens (target: first token < 1s on
|
||||
/// M-series). Everything stays on-device — this method must never hit the network.
|
||||
///
|
||||
/// The stub produces deterministic, plausible transformations so the capture →
|
||||
/// engine → replace loop and the entitlement gating can be exercised end-to-end
|
||||
/// without the multi-gigabyte model present.
|
||||
final class MLXEngine: AIEngine {
|
||||
private let ready: Bool
|
||||
|
||||
init(ready: Bool = true) {
|
||||
self.ready = ready
|
||||
}
|
||||
|
||||
var isReady: Bool { ready }
|
||||
|
||||
func run(_ action: AIAction, on text: String) async throws -> String {
|
||||
guard ready else { throw AIEngineError.modelNotReady }
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return text }
|
||||
|
||||
switch action {
|
||||
case .rewriteClarity, .rewrite:
|
||||
return tidy(trimmed)
|
||||
case .adjustTone(let tone):
|
||||
return apply(tone: tone, to: tidy(trimmed))
|
||||
case .ask(let instruction):
|
||||
return follow(instruction: instruction, on: trimmed)
|
||||
case .translate(let language):
|
||||
return "[\(language)] " + tidy(trimmed)
|
||||
case .applyMode(let mode):
|
||||
return applyMode(mode, to: trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
func detectTone(_ text: String) async -> Tone {
|
||||
let lower = text.lowercased()
|
||||
if lower.contains("!") || lower.contains("🚀") { return .punchy }
|
||||
if lower.contains("therefore") || lower.contains("hereby") { return .academic }
|
||||
if lower.contains("hey") || lower.contains("lol") { return .friendly }
|
||||
return .professional
|
||||
}
|
||||
|
||||
// MARK: - Deterministic stand-in transforms
|
||||
|
||||
private func tidy(_ s: String) -> String {
|
||||
var out = s
|
||||
out = out.replacingOccurrences(of: " i ", with: " I ")
|
||||
out = out.replacingOccurrences(of: "lmk", with: "let me know")
|
||||
out = out.replacingOccurrences(of: "u ", with: "you ")
|
||||
if let first = out.first, first.isLowercase {
|
||||
out.replaceSubrange(out.startIndex...out.startIndex, with: String(first).uppercased())
|
||||
}
|
||||
if let last = out.last, !".!?".contains(last) { out.append(".") }
|
||||
return out
|
||||
}
|
||||
|
||||
private func apply(tone: Tone, to s: String) -> String {
|
||||
switch tone {
|
||||
case .professional: return s
|
||||
case .friendly: return "Hi! " + s
|
||||
case .confident: return s.replacingOccurrences(of: "I think ", with: "")
|
||||
case .academic: return "It is worth noting that " + s.prefix(1).lowercased() + s.dropFirst()
|
||||
case .punchy: return s.replacingOccurrences(of: ". ", with: ".\n")
|
||||
}
|
||||
}
|
||||
|
||||
private func follow(instruction: String, on s: String) -> String {
|
||||
let i = instruction.lowercased()
|
||||
if i.contains("short") { return String(s.split(separator: " ").prefix(8).joined(separator: " ")) + "." }
|
||||
if i.contains("formal") { return apply(tone: .academic, to: tidy(s)) }
|
||||
if i.contains("translate") { return "[translated] " + tidy(s) }
|
||||
return tidy(s)
|
||||
}
|
||||
|
||||
private func applyMode(_ mode: WritingMode, to s: String) -> String {
|
||||
switch mode.id {
|
||||
case "email": return "Hi,\n\n\(tidy(s))\n\nThanks!"
|
||||
case "post": return apply(tone: .punchy, to: tidy(s))
|
||||
case "academic": return apply(tone: .academic, to: tidy(s))
|
||||
case "slack": return s.lowercased()
|
||||
case "code": return "// " + tidy(s)
|
||||
default: return tidy(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
111
Sources/GrammaGem/AI/ModeRegistry.swift
Normal file
111
Sources/GrammaGem/AI/ModeRegistry.swift
Normal file
@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
|
||||
/// Built-in Writing Modes (system-prompt presets for the local LLM).
|
||||
///
|
||||
/// Per the spec's soft-deterrent note (§5), the *free* mode (`polish`) ships in
|
||||
/// the public repo, while the paid modes' fully-tuned prompts ship only in the
|
||||
/// signed release. Here we include readable baseline prompts for all of them;
|
||||
/// swap the paid ones for your tuned versions in the release build.
|
||||
enum ModeRegistry {
|
||||
static let polish = WritingMode(
|
||||
id: "polish",
|
||||
name: "Polish",
|
||||
systemPrompt: """
|
||||
You are a careful copy editor. Lightly clean up the user's text: fix \
|
||||
grammar, spelling, and punctuation, and improve clarity, while \
|
||||
preserving their voice, meaning, and formatting. Return only the \
|
||||
revised text with no commentary.
|
||||
""",
|
||||
isPaid: false,
|
||||
lengthCap: nil,
|
||||
autoFormat: "Preserve original"
|
||||
)
|
||||
|
||||
static let email = WritingMode(
|
||||
id: "email",
|
||||
name: "Email",
|
||||
systemPrompt: """
|
||||
Rewrite the user's text as a professional, warm email. Keep it concise \
|
||||
and natural — never robotic. Add a brief greeting and sign-off only if \
|
||||
appropriate. Return only the email body.
|
||||
""",
|
||||
isPaid: true,
|
||||
lengthCap: 120,
|
||||
autoFormat: "Greeting + sign-off"
|
||||
)
|
||||
|
||||
static let post = WritingMode(
|
||||
id: "post",
|
||||
name: "Post",
|
||||
systemPrompt: """
|
||||
Rewrite the user's text as a punchy social post. Short lines, strong \
|
||||
verbs, no fluff. Keep it under 280 characters. Return only the post.
|
||||
""",
|
||||
isPaid: true,
|
||||
lengthCap: 280,
|
||||
autoFormat: "Line breaks, no fluff"
|
||||
)
|
||||
|
||||
static let slack = WritingMode(
|
||||
id: "slack",
|
||||
name: "Team Chat",
|
||||
systemPrompt: """
|
||||
Rewrite the user's text as a friendly, direct team-chat message. \
|
||||
Conversational, lowercase is fine, get to the point. Return only the message.
|
||||
""",
|
||||
isPaid: true,
|
||||
lengthCap: 60,
|
||||
autoFormat: "Inline, casual"
|
||||
)
|
||||
|
||||
static let academic = WritingMode(
|
||||
id: "academic",
|
||||
name: "Academic",
|
||||
systemPrompt: """
|
||||
Rewrite the user's text in a formal, academic register. Hedge claims \
|
||||
appropriately, prefer precise vocabulary, and use complete sentences. \
|
||||
Return only the revised text.
|
||||
""",
|
||||
isPaid: true,
|
||||
lengthCap: nil,
|
||||
autoFormat: "Formal, hedged"
|
||||
)
|
||||
|
||||
static let code = WritingMode(
|
||||
id: "code",
|
||||
name: "Code Comment",
|
||||
systemPrompt: """
|
||||
Rewrite the user's text as a clear code comment / commit message: \
|
||||
imperative mood, concise, technical. Return only the comment text.
|
||||
""",
|
||||
isPaid: true,
|
||||
lengthCap: nil,
|
||||
autoFormat: "Imperative, terse"
|
||||
)
|
||||
|
||||
/// Everything the app knows about.
|
||||
static let all: [WritingMode] = [polish, email, post, slack, academic, code]
|
||||
|
||||
/// Modes a given tier may use (free: Polish only; paid: all + custom).
|
||||
static func available(for tier: Tier) -> [WritingMode] {
|
||||
tier.isPaid ? all : all.filter { !$0.isPaid }
|
||||
}
|
||||
|
||||
static func mode(id: String) -> WritingMode? {
|
||||
all.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// Map a frontmost-app bundle identifier to the Mode it should auto-apply.
|
||||
/// Used by the App-Aware feature (paid).
|
||||
static func mode(forBundleID bundleID: String) -> WritingMode? {
|
||||
switch bundleID {
|
||||
case "com.apple.mail": return email
|
||||
case "com.tinyspeck.slackmacgap": return slack
|
||||
case "com.apple.MobileSMS": return slack
|
||||
case "notion.id", "com.notion.desktop": return polish
|
||||
case "com.microsoft.VSCode", "com.apple.dt.Xcode": return code
|
||||
case "com.apple.Safari", "com.google.Chrome": return polish
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
58
Sources/GrammaGem/AI/ModelManager.swift
Normal file
58
Sources/GrammaGem/AI/ModelManager.swift
Normal file
@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
|
||||
/// Manages the one-time download of the local LLM weights from Hugging Face.
|
||||
/// This is one of the only three network touchpoints in the product (model
|
||||
/// download, license activate/validate, update checks) — and it never sends
|
||||
/// user text.
|
||||
@MainActor
|
||||
final class ModelManager: ObservableObject {
|
||||
enum State: Equatable {
|
||||
case notDownloaded
|
||||
case downloading(progress: Double)
|
||||
case ready
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@Published private(set) var state: State = .notDownloaded
|
||||
@Published var selectedRepo: String = AppConfig.Model.defaultRepo
|
||||
|
||||
/// Local cache directory for downloaded weights.
|
||||
var modelsDirectory: URL {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
return base.appendingPathComponent("GrammaGem/Models", isDirectory: true)
|
||||
}
|
||||
|
||||
func isModelPresent(repo: String) -> Bool {
|
||||
let dir = modelsDirectory.appendingPathComponent(repo.replacingOccurrences(of: "/", with: "_"))
|
||||
return FileManager.default.fileExists(atPath: dir.path)
|
||||
}
|
||||
|
||||
/// TODO(real-integration): stream the model files from Hugging Face into
|
||||
/// `modelsDirectory` (resumable, with progress), then hand the path to the
|
||||
/// MLX runtime. This stub simulates the lifecycle so onboarding UI works.
|
||||
func download(repo: String? = nil) async {
|
||||
let target = repo ?? selectedRepo
|
||||
selectedRepo = target
|
||||
|
||||
if isModelPresent(repo: target) {
|
||||
state = .ready
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: modelsDirectory, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
state = .failed(error.localizedDescription)
|
||||
return
|
||||
}
|
||||
|
||||
// Simulated progress (replace with real URLSession download tasks).
|
||||
state = .downloading(progress: 0)
|
||||
for step in 1...10 {
|
||||
try? await Task.sleep(nanoseconds: 120_000_000)
|
||||
state = .downloading(progress: Double(step) / 10.0)
|
||||
}
|
||||
Log.ai.info("Model marked ready (stubbed download): \(target, privacy: .public)")
|
||||
state = .ready
|
||||
}
|
||||
}
|
||||
197
Sources/GrammaGem/App/AppState.swift
Normal file
197
Sources/GrammaGem/App/AppState.swift
Normal file
@ -0,0 +1,197 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Root application model. Owns the engines, managers, and the system-wide
|
||||
/// coordinator, wires the global hotkeys, and presents the on-demand windows.
|
||||
@MainActor
|
||||
final class AppState: ObservableObject {
|
||||
static let shared = AppState()
|
||||
|
||||
// Engines (Layer 1 + Layer 2)
|
||||
let grammar: GrammarEngine
|
||||
let ai: AIEngine
|
||||
|
||||
// Managers
|
||||
let permissions: Permissions
|
||||
let model: ModelManager
|
||||
let license: LicenseManager
|
||||
let gate: FeatureGate
|
||||
let detector: AppDetector
|
||||
let hotkeys: HotkeyManager
|
||||
let dictionary: PersonalDictionary
|
||||
private let capture: TextCapture
|
||||
let coordinator: TextReplacementCoordinator
|
||||
|
||||
// UI state
|
||||
@Published var lastStatus: String = "Ready"
|
||||
@Published var askText: String = ""
|
||||
@Published var appAwareEnabled: Bool = false
|
||||
|
||||
private var onboardingWindow: NSWindow?
|
||||
private var askWindow: NSWindow?
|
||||
|
||||
private init() {
|
||||
let harper = HarperEngine()
|
||||
grammar = harper
|
||||
ai = MLXEngine(ready: true)
|
||||
permissions = Permissions()
|
||||
model = ModelManager()
|
||||
license = LicenseManager()
|
||||
gate = FeatureGate(license: license)
|
||||
detector = AppDetector()
|
||||
capture = TextCapture()
|
||||
hotkeys = HotkeyManager()
|
||||
dictionary = PersonalDictionary()
|
||||
coordinator = TextReplacementCoordinator(
|
||||
capture: capture, grammar: harper, ai: ai, gate: gate, detector: detector)
|
||||
}
|
||||
|
||||
/// Called once at launch (from the AppDelegate).
|
||||
func start() {
|
||||
// Menu-bar app: no Dock icon (works even without LSUIElement in Info.plist).
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
|
||||
hotkeys.onAction = { [weak self] action in
|
||||
Task { await self?.handle(action) }
|
||||
}
|
||||
hotkeys.registerDefaults()
|
||||
|
||||
permissions.refresh()
|
||||
appAwareEnabled = gate.appAwareEnabled
|
||||
syncIgnoreList()
|
||||
|
||||
if !permissions.accessibilityTrusted {
|
||||
showOnboarding()
|
||||
}
|
||||
|
||||
Task { await license.validateIfNeeded() }
|
||||
Log.app.info("GrammaGem started. Licensed: \(self.license.isLicensed, privacy: .public)")
|
||||
}
|
||||
|
||||
// MARK: - Hotkey handling
|
||||
|
||||
func handle(_ action: HotkeyManager.Action) async {
|
||||
switch action {
|
||||
case .fix:
|
||||
await runFix()
|
||||
case .ask:
|
||||
showAsk()
|
||||
}
|
||||
}
|
||||
|
||||
func runFix() async {
|
||||
present(await coordinator.handleFix())
|
||||
}
|
||||
|
||||
func runAsk() async {
|
||||
let instruction = askText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instruction.isEmpty else { return }
|
||||
present(await coordinator.handleAsk(instruction))
|
||||
askWindow?.close()
|
||||
}
|
||||
|
||||
/// Apply the Mode suggested by the frontmost app (App-Aware, paid).
|
||||
func runAppAwareRewrite() async {
|
||||
guard gate.appAwareEnabled else {
|
||||
present(.blockedByEntitlement("App-aware switching is part of the lifetime upgrade."))
|
||||
return
|
||||
}
|
||||
guard let mode = detector.suggestedMode() else {
|
||||
lastStatus = "No mode mapped for the current app."
|
||||
return
|
||||
}
|
||||
present(await coordinator.handleApplyMode(mode))
|
||||
}
|
||||
|
||||
private func present(_ outcome: ProcessOutcome) {
|
||||
switch outcome {
|
||||
case .replaced:
|
||||
lastStatus = "Fixed ✓"
|
||||
case .noSelection:
|
||||
lastStatus = "Select some text first."
|
||||
case .blockedByEntitlement(let reason):
|
||||
lastStatus = reason
|
||||
notify(title: "Upgrade required", body: reason)
|
||||
case .failed(let message):
|
||||
lastStatus = message
|
||||
}
|
||||
}
|
||||
|
||||
private func notify(title: String, body: String) {
|
||||
// Lightweight, content-free alert (no user text ever leaves the device).
|
||||
let alert = NSAlert()
|
||||
alert.messageText = title
|
||||
alert.informativeText = body
|
||||
alert.addButton(withTitle: "OK")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
// MARK: - On-demand windows (AppKit-hosted SwiftUI; reliable for menu-bar apps)
|
||||
|
||||
func showOnboarding() {
|
||||
if onboardingWindow == nil {
|
||||
let host = NSHostingController(rootView: OnboardingView().environmentObject(self))
|
||||
let win = NSWindow(contentViewController: host)
|
||||
win.title = "Welcome to GrammaGem"
|
||||
win.styleMask = [.titled, .closable, .fullSizeContentView]
|
||||
win.setContentSize(NSSize(width: 540, height: 600))
|
||||
win.isReleasedWhenClosed = false
|
||||
win.center()
|
||||
onboardingWindow = win
|
||||
}
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
onboardingWindow?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
func showAsk() {
|
||||
askText = ""
|
||||
if askWindow == nil {
|
||||
let host = NSHostingController(rootView: AskView().environmentObject(self))
|
||||
let win = NSWindow(contentViewController: host)
|
||||
win.title = "Ask GrammaGem"
|
||||
win.styleMask = [.titled, .closable]
|
||||
win.setContentSize(NSSize(width: 460, height: 220))
|
||||
win.isReleasedWhenClosed = false
|
||||
win.center()
|
||||
win.level = .floating
|
||||
askWindow = win
|
||||
}
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
askWindow?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
func showOnboardingDismiss() {
|
||||
onboardingWindow?.close()
|
||||
}
|
||||
|
||||
func showAskDismiss() {
|
||||
askWindow?.close()
|
||||
}
|
||||
|
||||
// MARK: - Personal dictionary (gated adds)
|
||||
|
||||
@discardableResult
|
||||
func addDictionaryEntry(_ word: String) -> FeatureGate.Decision {
|
||||
let decision = gate.canAddDictionaryEntry(currentCount: dictionary.entries.count)
|
||||
if case .allowed = decision {
|
||||
dictionary.add(word)
|
||||
syncIgnoreList()
|
||||
}
|
||||
return decision
|
||||
}
|
||||
|
||||
func removeDictionaryEntry(_ word: String) {
|
||||
dictionary.remove(word)
|
||||
syncIgnoreList()
|
||||
}
|
||||
|
||||
private func syncIgnoreList() {
|
||||
grammar.ignoreList = Set(dictionary.entries.map { $0.lowercased() })
|
||||
}
|
||||
|
||||
func quit() {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
39
Sources/GrammaGem/App/AskView.swift
Normal file
39
Sources/GrammaGem/App/AskView.swift
Normal file
@ -0,0 +1,39 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The "Ask" popover: type an instruction ("make it shorter", "translate to
|
||||
/// German") that the local LLM applies to the current selection, in place.
|
||||
struct AskView: View {
|
||||
@EnvironmentObject private var app: AppState
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Ask GrammaGem")
|
||||
.font(.headline)
|
||||
Text("Acts on the text selected in the frontmost app. Runs on-device.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("e.g. make it more formal", text: $app.askText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($focused)
|
||||
.onSubmit { Task { await app.runAsk() } }
|
||||
|
||||
HStack {
|
||||
if !app.gate.entitlements.unlimitedAIActions {
|
||||
Text("\(app.gate.remainingAIActionsToday) free actions left today")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Cancel") { app.showAskDismiss() }
|
||||
Button("Run") { Task { await app.runAsk() } }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(app.askText.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.onAppear { focused = true }
|
||||
}
|
||||
}
|
||||
32
Sources/GrammaGem/App/GrammaGemApp.swift
Normal file
32
Sources/GrammaGem/App/GrammaGemApp.swift
Normal file
@ -0,0 +1,32 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// App entry point. A menu-bar (`LSUIElement`) SwiftUI app: the only persistent
|
||||
/// scene is the `MenuBarExtra`; Settings uses the standard scene; Onboarding and
|
||||
/// Ask are AppKit-hosted windows opened on demand (see `AppState`).
|
||||
@main
|
||||
struct GrammaGemApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@StateObject private var app = AppState.shared
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra("GrammaGem", systemImage: "pencil.and.scribble") {
|
||||
MenuBarContent()
|
||||
.environmentObject(app)
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
|
||||
Settings {
|
||||
SettingsView()
|
||||
.environmentObject(app)
|
||||
.frame(width: 560, height: 520)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs launch-time wiring (hotkeys, permissions, license validation).
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
AppState.shared.start()
|
||||
}
|
||||
}
|
||||
101
Sources/GrammaGem/App/MenuBarContent.swift
Normal file
101
Sources/GrammaGem/App/MenuBarContent.swift
Normal file
@ -0,0 +1,101 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The dropdown shown from the menu-bar icon.
|
||||
struct MenuBarContent: View {
|
||||
@EnvironmentObject private var app: AppState
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
header
|
||||
|
||||
Divider()
|
||||
|
||||
actionRow(title: "Fix selection", shortcut: "⌘;") {
|
||||
Task { await app.runFix() }
|
||||
}
|
||||
actionRow(title: "Ask GrammaGem…", shortcut: "⌘'") {
|
||||
app.showAsk()
|
||||
}
|
||||
if app.gate.appAwareEnabled {
|
||||
actionRow(title: "Apply app-aware mode", shortcut: nil) {
|
||||
Task { await app.runAppAwareRewrite() }
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
usageRow
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Button("Settings…") { openSettings() }
|
||||
Spacer()
|
||||
Button("Quit") { app.quit() }
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
|
||||
Text(app.lastStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(width: 280)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "pencil.and.scribble")
|
||||
.foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("GrammaGem").font(.headline)
|
||||
Text(app.license.tier.displayName + (app.license.isLicensed ? " · licensed" : " · free"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if !app.permissions.accessibilityTrusted {
|
||||
Button {
|
||||
app.showOnboarding()
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Accessibility permission needed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var usageRow: some View {
|
||||
if app.gate.entitlements.unlimitedAIActions {
|
||||
Label("Unlimited AI actions", systemImage: "infinity")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
let used = app.gate.aiActionsUsedToday
|
||||
let cap = app.gate.entitlements.dailyAIActionCap
|
||||
Label("\(max(0, cap - used)) of \(cap) free AI actions left today",
|
||||
systemImage: "bolt")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func actionRow(title: String, shortcut: String?, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer()
|
||||
if let shortcut {
|
||||
Text(shortcut).foregroundStyle(.secondary).font(.callout.monospaced())
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
105
Sources/GrammaGem/App/OnboardingView.swift
Normal file
105
Sources/GrammaGem/App/OnboardingView.swift
Normal file
@ -0,0 +1,105 @@
|
||||
import SwiftUI
|
||||
|
||||
/// First-run onboarding: explains why GrammaGem needs Accessibility, deep-links
|
||||
/// to System Settings, and advances automatically once permission is granted.
|
||||
struct OnboardingView: View {
|
||||
@EnvironmentObject private var app: AppState
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Welcome to GrammaGem")
|
||||
.font(.largeTitle.bold())
|
||||
Text("A private, on-device writing assistant that works in every app.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
stepCard(
|
||||
number: 1,
|
||||
title: "Grant Accessibility access",
|
||||
body: "GrammaGem reads the text you select and writes the correction back. macOS requires Accessibility permission for this. Your text is processed on-device and never leaves your Mac.",
|
||||
granted: app.permissions.accessibilityTrusted
|
||||
) {
|
||||
HStack {
|
||||
Button("Open System Settings") { app.permissions.openAccessibilitySettings() }
|
||||
Button("Request permission") {
|
||||
app.permissions.requestAccessibility()
|
||||
app.permissions.startPollingUntilGranted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stepCard(
|
||||
number: 2,
|
||||
title: "Download the on-device model (optional)",
|
||||
body: "Grammar & spelling work instantly with no download. The AI rewrite/tone/Ask features use a small model downloaded once from Hugging Face — then everything runs offline.",
|
||||
granted: app.model.state == .ready
|
||||
) {
|
||||
ModelDownloadControls()
|
||||
.environmentObject(app)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(app.permissions.accessibilityTrusted ? "Done" : "Continue without permission") {
|
||||
app.showOnboardingDismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
.padding(28)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.onAppear { app.permissions.refresh() }
|
||||
}
|
||||
|
||||
private func stepCard<Controls: View>(
|
||||
number: Int, title: String, body: String, granted: Bool,
|
||||
@ViewBuilder controls: () -> Controls
|
||||
) -> some View {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ZStack {
|
||||
Circle().fill(granted ? Color.green : Color.accentColor.opacity(0.15))
|
||||
.frame(width: 30, height: 30)
|
||||
if granted {
|
||||
Image(systemName: "checkmark").foregroundStyle(.white).font(.caption.bold())
|
||||
} else {
|
||||
Text("\(number)").font(.callout.bold()).foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title).font(.headline)
|
||||
Text(body).font(.callout).foregroundStyle(.secondary)
|
||||
controls()
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
/// Model download progress + trigger, shared by onboarding and settings.
|
||||
struct ModelDownloadControls: View {
|
||||
@EnvironmentObject private var app: AppState
|
||||
|
||||
var body: some View {
|
||||
switch app.model.state {
|
||||
case .notDownloaded:
|
||||
Button("Download model") { Task { await app.model.download() } }
|
||||
case .downloading(let progress):
|
||||
ProgressView(value: progress) { Text("Downloading… \(Int(progress * 100))%") }
|
||||
.frame(maxWidth: 260)
|
||||
case .ready:
|
||||
Label("Model ready", systemImage: "checkmark.seal.fill")
|
||||
.foregroundStyle(.green)
|
||||
case .failed(let message):
|
||||
VStack(alignment: .leading) {
|
||||
Label("Download failed", systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.orange)
|
||||
Text(message).font(.caption).foregroundStyle(.secondary)
|
||||
Button("Retry") { Task { await app.model.download() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
192
Sources/GrammaGem/App/SettingsView.swift
Normal file
192
Sources/GrammaGem/App/SettingsView.swift
Normal file
@ -0,0 +1,192 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
GeneralSettings()
|
||||
.tabItem { Label("General", systemImage: "gearshape") }
|
||||
LicenseSettings()
|
||||
.tabItem { Label("License", systemImage: "key") }
|
||||
ModelSettings()
|
||||
.tabItem { Label("Model", systemImage: "cpu") }
|
||||
DictionarySettings()
|
||||
.tabItem { Label("Dictionary", systemImage: "character.book.closed") }
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - General
|
||||
|
||||
private struct GeneralSettings: View {
|
||||
@EnvironmentObject private var app: AppState
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Shortcuts") {
|
||||
LabeledContent("Fix selection", value: "⌘;")
|
||||
LabeledContent("Ask GrammaGem", value: "⌘'")
|
||||
Text("Custom shortcuts coming via the KeyboardShortcuts recorder (see README).")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Section("App-aware switching") {
|
||||
if app.gate.appAwareEnabled {
|
||||
Toggle("Auto-apply a Writing Mode based on the active app", isOn: $app.appAwareEnabled)
|
||||
} else {
|
||||
Label("App-aware switching is part of the lifetime upgrade.", systemImage: "lock")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Section("Permissions") {
|
||||
LabeledContent("Accessibility",
|
||||
value: app.permissions.accessibilityTrusted ? "Granted" : "Needed")
|
||||
if !app.permissions.accessibilityTrusted {
|
||||
Button("Open onboarding") { app.showOnboarding() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - License
|
||||
|
||||
private struct LicenseSettings: View {
|
||||
@EnvironmentObject private var app: AppState
|
||||
@State private var keyField = ""
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Plan") {
|
||||
LabeledContent("Current tier", value: app.license.tier.displayName)
|
||||
LabeledContent("Device cap", value: "\(app.license.tier.deviceCap) Mac\(app.license.tier.deviceCap == 1 ? "" : "s")")
|
||||
}
|
||||
|
||||
if app.license.isLicensed {
|
||||
Section("This device") {
|
||||
Button(app.license.isWorking ? "Working…" : "Deactivate this device") {
|
||||
Task { await app.license.deactivateThisDevice() }
|
||||
}
|
||||
.disabled(app.license.isWorking)
|
||||
Text("Frees a slot so you can move the license to another Mac.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Section("Activate") {
|
||||
TextField("Paste your license key", text: $keyField)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button(app.license.isWorking ? "Activating…" : "Activate") {
|
||||
Task { await app.license.activate(key: keyField) }
|
||||
}
|
||||
.disabled(app.license.isWorking || keyField.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
if let error = app.license.lastError {
|
||||
Section {
|
||||
Label(error, systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Link("Manage your license", destination: AppConfig.modelPortalURL)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Model
|
||||
|
||||
private struct ModelSettings: View {
|
||||
@EnvironmentObject private var app: AppState
|
||||
@ObservedObject private var model: ModelManager
|
||||
|
||||
init() {
|
||||
_model = ObservedObject(wrappedValue: AppState.shared.model)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("On-device model") {
|
||||
Picker("Model", selection: $model.selectedRepo) {
|
||||
Text("Qwen2.5-3B (default)").tag(AppConfig.Model.defaultRepo)
|
||||
Text("Qwen2.5-7B (more RAM)")
|
||||
.tag(AppConfig.Model.largeRepo)
|
||||
}
|
||||
.disabled(!app.gate.entitlements.canUseLargeModel)
|
||||
if !app.gate.entitlements.canUseLargeModel {
|
||||
Label("The larger 7B model is available on paid plans.", systemImage: "lock")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
ModelDownloadControls().environmentObject(app)
|
||||
}
|
||||
Section {
|
||||
Text("Downloaded once from Hugging Face, then everything runs offline. The model is never bundled in the app binary.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dictionary
|
||||
|
||||
private struct DictionarySettings: View {
|
||||
@EnvironmentObject private var app: AppState
|
||||
@ObservedObject private var dict: PersonalDictionary
|
||||
@State private var newWord = ""
|
||||
@State private var capMessage: String?
|
||||
|
||||
init() {
|
||||
// Bound at render via environment; we re-observe the shared instance.
|
||||
_dict = ObservedObject(wrappedValue: AppState.shared.dictionary)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
TextField("Add a word GrammaGem should never correct", text: $newWord)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit(add)
|
||||
Button("Add", action: add).disabled(newWord.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
|
||||
if let capMessage {
|
||||
Label(capMessage, systemImage: "lock").font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
let cap = app.gate.entitlements.dictionaryCap
|
||||
Text(cap == Int.max
|
||||
? "\(dict.entries.count) entries · unlimited"
|
||||
: "\(dict.entries.count) / \(cap) entries")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
|
||||
List {
|
||||
ForEach(dict.entries, id: \.self) { word in
|
||||
HStack {
|
||||
Text(word)
|
||||
Spacer()
|
||||
Button {
|
||||
app.removeDictionaryEntry(word)
|
||||
} label: { Image(systemName: "trash") }
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 220)
|
||||
}
|
||||
}
|
||||
|
||||
private func add() {
|
||||
let decision = app.addDictionaryEntry(newWord)
|
||||
switch decision {
|
||||
case .allowed:
|
||||
newWord = ""
|
||||
capMessage = nil
|
||||
case .denied(let message):
|
||||
capMessage = message
|
||||
}
|
||||
}
|
||||
}
|
||||
50
Sources/GrammaGem/Core/AppConfig.swift
Normal file
50
Sources/GrammaGem/Core/AppConfig.swift
Normal file
@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
/// Compile-time configuration. The Lemon Squeezy identifiers are hard-coded so
|
||||
/// the licensing layer can *hard-verify* that a key belongs to GrammaGem (and to
|
||||
/// the claimed tier) rather than to some other Lemon Squeezy product.
|
||||
/// TODO(real-integration): replace every `TODO_*` value with your real IDs.
|
||||
enum AppConfig {
|
||||
static let appName = "GrammaGem"
|
||||
static let bundleIdentifier = "com.grammagem.app" // TODO(real-integration)
|
||||
static let supportEmail = "support@grammagem.app" // TODO(real-integration)
|
||||
static let modelPortalURL = URL(string: "https://grammagem.app/license")! // TODO
|
||||
|
||||
/// Lemon Squeezy License API (app talks to it directly — no server of ours).
|
||||
enum LemonSqueezy {
|
||||
static let apiBase = URL(string: "https://api.lemonsqueezy.com/v1")!
|
||||
static let expectedStoreID = "TODO_STORE_ID"
|
||||
static let expectedProductID = "TODO_PRODUCT_ID"
|
||||
/// One variant per tier; the variant's activation limit is the device cap.
|
||||
static let variantID: [Tier: String] = [
|
||||
.solo: "TODO_SOLO_VARIANT_ID",
|
||||
.personal: "TODO_PERSONAL_VARIANT_ID",
|
||||
.studio: "TODO_STUDIO_VARIANT_ID",
|
||||
]
|
||||
}
|
||||
|
||||
/// Local LLM weights, pulled from Hugging Face on first run (never bundled).
|
||||
enum Model {
|
||||
static let defaultRepo = "mlx-community/Qwen2.5-3B-Instruct-4bit" // Apache-2.0
|
||||
static let largeRepo = "mlx-community/Qwen2.5-7B-Instruct-4bit" // optional, paid tiers
|
||||
static let huggingFaceBase = "https://huggingface.co"
|
||||
}
|
||||
|
||||
/// Free-tier limits + license validation cadence (see spec §5/§6).
|
||||
enum Limits {
|
||||
static let freeDailyAIActions = 10
|
||||
static let freeDictionaryCap = 25
|
||||
static let validationIntervalDays = 7
|
||||
static let offlineGraceDays = 30
|
||||
}
|
||||
|
||||
/// Default global hotkey: ⌘; (Carbon key code 41, Carbon cmdKey mask 256).
|
||||
/// Stored as raw integers here to keep this file free of the Carbon import.
|
||||
enum Hotkey {
|
||||
static let defaultKeyCode: UInt32 = 41 // kVK_ANSI_Semicolon
|
||||
static let defaultModifiers: UInt32 = 256 // cmdKey
|
||||
// A second hotkey opens the "Ask" popover (default ⌘' = key 39).
|
||||
static let askKeyCode: UInt32 = 39 // kVK_ANSI_Quote
|
||||
static let askModifiers: UInt32 = 256
|
||||
}
|
||||
}
|
||||
13
Sources/GrammaGem/Core/Logger.swift
Normal file
13
Sources/GrammaGem/Core/Logger.swift
Normal file
@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Thin wrapper over `os.Logger`. Privacy-first: we never log user text.
|
||||
enum Log {
|
||||
private static let subsystem = AppConfig.bundleIdentifier
|
||||
|
||||
static let system = Logger(subsystem: subsystem, category: "system")
|
||||
static let grammar = Logger(subsystem: subsystem, category: "grammar")
|
||||
static let ai = Logger(subsystem: subsystem, category: "ai")
|
||||
static let licensing = Logger(subsystem: subsystem, category: "licensing")
|
||||
static let app = Logger(subsystem: subsystem, category: "app")
|
||||
}
|
||||
112
Sources/GrammaGem/Core/Models.swift
Normal file
112
Sources/GrammaGem/Core/Models.swift
Normal file
@ -0,0 +1,112 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Plans & tiers
|
||||
|
||||
/// License tiers. `free` is the unlicensed default; paid tiers carry a device cap.
|
||||
enum Tier: String, Codable, CaseIterable, Hashable {
|
||||
case free
|
||||
case solo
|
||||
case personal
|
||||
case studio
|
||||
|
||||
/// Number of Macs a license may be activated on (enforced via Lemon Squeezy
|
||||
/// variant activation limits + our offline cap).
|
||||
var deviceCap: Int {
|
||||
switch self {
|
||||
case .free: return 1
|
||||
case .solo: return 1
|
||||
case .personal: return 2
|
||||
case .studio: return 4
|
||||
}
|
||||
}
|
||||
|
||||
var isPaid: Bool { self != .free }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .free: return "Free"
|
||||
case .solo: return "Solo"
|
||||
case .personal: return "Personal"
|
||||
case .studio: return "Studio"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Grammar (Layer 1)
|
||||
|
||||
/// Categories Harper surfaces. Layer-1 corrections are deterministic + instant.
|
||||
enum GrammarKind: String, Codable {
|
||||
case spelling
|
||||
case grammar
|
||||
case punctuation
|
||||
case phrasing
|
||||
case repetition
|
||||
case capitalization
|
||||
}
|
||||
|
||||
/// A single grammar suggestion over a span of the input text.
|
||||
struct Suggestion: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
/// UTF-16 offset + length, so it maps cleanly onto `NSRange` / AX ranges.
|
||||
let location: Int
|
||||
let length: Int
|
||||
let original: String
|
||||
let replacement: String
|
||||
let kind: GrammarKind
|
||||
let message: String
|
||||
|
||||
var range: NSRange { NSRange(location: location, length: length) }
|
||||
}
|
||||
|
||||
// MARK: - AI actions (Layer 2)
|
||||
|
||||
/// The local-LLM-powered actions. All of these share the free daily counter.
|
||||
enum AIAction: Equatable {
|
||||
case rewriteClarity // clarity / conciseness
|
||||
case adjustTone(Tone) // detect + shift tone
|
||||
case rewrite // full paraphrase
|
||||
case ask(String) // free-form instruction ("make formal", "shorten")
|
||||
case translate(language: String)
|
||||
case applyMode(WritingMode) // Mode-based rewrite
|
||||
|
||||
/// Whether this action is gated behind a paid license (free tier still gets
|
||||
/// a limited daily allowance of these — see `FeatureGate`).
|
||||
var isPaidAction: Bool {
|
||||
switch self {
|
||||
case .applyMode(let mode): return mode.isPaid
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Tone: String, Codable, CaseIterable {
|
||||
case professional
|
||||
case friendly
|
||||
case confident
|
||||
case academic
|
||||
case punchy
|
||||
}
|
||||
|
||||
// MARK: - Writing modes
|
||||
|
||||
/// A Writing Mode is just a named system-prompt preset for the local LLM.
|
||||
struct WritingMode: Identifiable, Codable, Equatable {
|
||||
var id: String
|
||||
var name: String
|
||||
var systemPrompt: String
|
||||
/// Free tier ships only the `polish` mode; the rest are paid.
|
||||
var isPaid: Bool
|
||||
/// Optional length cap / auto-format hints surfaced in App-Aware settings.
|
||||
var lengthCap: Int?
|
||||
var autoFormat: String?
|
||||
}
|
||||
|
||||
// MARK: - Engine results
|
||||
|
||||
/// Outcome of running the system-wide capture → engine → replace loop.
|
||||
enum ProcessOutcome: Equatable {
|
||||
case replaced(String) // text was rewritten in place
|
||||
case noSelection // nothing was selected to act on
|
||||
case blockedByEntitlement(String) // hit a paywall / daily limit
|
||||
case failed(String) // capture/replace/engine error (message)
|
||||
}
|
||||
32
Sources/GrammaGem/Entitlements/Entitlements.swift
Normal file
32
Sources/GrammaGem/Entitlements/Entitlements.swift
Normal file
@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
|
||||
/// A single value object derived from license tier that every paid path checks.
|
||||
/// Free is deliberately useful (full Harper grammar, system-wide) while the AI
|
||||
/// superpowers + multi-device are the upgrade triggers (spec §5).
|
||||
struct Entitlements: Equatable {
|
||||
let tier: Tier
|
||||
|
||||
/// Layer-1 grammar is ALWAYS free + unlimited (the open-source core).
|
||||
var unlimitedGrammar: Bool { true }
|
||||
|
||||
/// Layer-2 AI actions: unlimited when paid; capped per day on free.
|
||||
var unlimitedAIActions: Bool { tier.isPaid }
|
||||
var dailyAIActionCap: Int {
|
||||
tier.isPaid ? Int.max : AppConfig.Limits.freeDailyAIActions
|
||||
}
|
||||
|
||||
/// Writing Modes: free gets Polish only; paid gets all + custom.
|
||||
var allModes: Bool { tier.isPaid }
|
||||
|
||||
/// Paid-only features.
|
||||
var appAwareSwitching: Bool { tier.isPaid }
|
||||
var snippets: Bool { tier.isPaid }
|
||||
var canUseLargeModel: Bool { tier.isPaid }
|
||||
|
||||
/// Personal dictionary: 25 entries free, unlimited paid.
|
||||
var dictionaryCap: Int {
|
||||
tier.isPaid ? Int.max : AppConfig.Limits.freeDictionaryCap
|
||||
}
|
||||
|
||||
var deviceCap: Int { tier.deviceCap }
|
||||
}
|
||||
101
Sources/GrammaGem/Entitlements/FeatureGate.swift
Normal file
101
Sources/GrammaGem/Entitlements/FeatureGate.swift
Normal file
@ -0,0 +1,101 @@
|
||||
import Foundation
|
||||
|
||||
/// The runtime gate every paid path goes through. Combines current `Entitlements`
|
||||
/// (from the license) with a local daily counter for the free tier's AI allowance.
|
||||
@MainActor
|
||||
final class FeatureGate: ObservableObject {
|
||||
enum Decision: Equatable {
|
||||
case allowed
|
||||
case denied(String)
|
||||
}
|
||||
|
||||
private let license: LicenseManager
|
||||
private let counter = DailyCounter()
|
||||
|
||||
/// Bumped to nudge SwiftUI when the counter changes (it's stored externally).
|
||||
@Published private(set) var usageTick = 0
|
||||
|
||||
init(license: LicenseManager) {
|
||||
self.license = license
|
||||
}
|
||||
|
||||
var entitlements: Entitlements { license.entitlements }
|
||||
|
||||
// MARK: - AI gating (rewrite / tone / Ask / translate / paid Modes share this)
|
||||
|
||||
func authorizeAI(_ action: AIAction) -> Decision {
|
||||
let ent = entitlements
|
||||
|
||||
// Paid Modes are unavailable on free (only Polish ships free).
|
||||
if case .applyMode(let mode) = action, mode.isPaid, !ent.allModes {
|
||||
return .denied("“\(mode.name)” mode is part of the lifetime upgrade.")
|
||||
}
|
||||
|
||||
if ent.unlimitedAIActions { return .allowed }
|
||||
|
||||
guard remainingAIActionsToday > 0 else {
|
||||
return .denied("You've used today's \(ent.dailyAIActionCap) free AI actions. Upgrade for unlimited.")
|
||||
}
|
||||
return .allowed
|
||||
}
|
||||
|
||||
func recordAIActionUsed() {
|
||||
guard !entitlements.unlimitedAIActions else { return }
|
||||
counter.increment()
|
||||
usageTick &+= 1
|
||||
}
|
||||
|
||||
var aiActionsUsedToday: Int { counter.countToday() }
|
||||
|
||||
var remainingAIActionsToday: Int {
|
||||
let ent = entitlements
|
||||
if ent.unlimitedAIActions { return Int.max }
|
||||
return max(0, ent.dailyAIActionCap - counter.countToday())
|
||||
}
|
||||
|
||||
// MARK: - Other gates
|
||||
|
||||
func availableModes() -> [WritingMode] {
|
||||
ModeRegistry.available(for: license.tier)
|
||||
}
|
||||
|
||||
var appAwareEnabled: Bool { entitlements.appAwareSwitching }
|
||||
var snippetsEnabled: Bool { entitlements.snippets }
|
||||
|
||||
/// Can the user add another personal-dictionary entry given the current count?
|
||||
func canAddDictionaryEntry(currentCount: Int) -> Decision {
|
||||
if currentCount < entitlements.dictionaryCap { return .allowed }
|
||||
return .denied("Free dictionary holds \(entitlements.dictionaryCap) entries. Upgrade for unlimited.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Free-tier AI action counter. Persists `{date, count}` in UserDefaults and
|
||||
/// resets at local midnight (compares the stored local date string to today's).
|
||||
final class DailyCounter {
|
||||
private let key = "GrammaGem.aiActions.daily"
|
||||
private let defaults = UserDefaults.standard
|
||||
|
||||
private static let formatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.timeZone = .current // local midnight reset
|
||||
return f
|
||||
}()
|
||||
|
||||
private func today() -> String { Self.formatter.string(from: Date()) }
|
||||
|
||||
func countToday() -> Int {
|
||||
guard let stored = defaults.dictionary(forKey: key),
|
||||
let date = stored["date"] as? String, date == today()
|
||||
else { return 0 }
|
||||
return stored["count"] as? Int ?? 0
|
||||
}
|
||||
|
||||
func increment() {
|
||||
let next = countToday() + 1
|
||||
defaults.set(["date": today(), "count": next], forKey: key)
|
||||
}
|
||||
|
||||
func reset() { defaults.removeObject(forKey: key) }
|
||||
}
|
||||
30
Sources/GrammaGem/Grammar/GrammarEngine.swift
Normal file
30
Sources/GrammaGem/Grammar/GrammarEngine.swift
Normal file
@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
/// Layer 1 — deterministic, instant, offline grammar checking.
|
||||
///
|
||||
/// In production this is backed by **Harper** (Rust, Apache-2.0) via a C-FFI
|
||||
/// static lib (see `harper-ffi/`) or a bundled `harper-ls` subprocess. The
|
||||
/// protocol keeps the rest of the app independent of that integration.
|
||||
protocol GrammarEngine: AnyObject {
|
||||
/// Return suggestions over `text` (non-destructive; UI draws underlines).
|
||||
func check(_ text: String) -> [Suggestion]
|
||||
|
||||
/// Apply every suggestion to produce a corrected string (used by the
|
||||
/// "fix in place" hotkey path).
|
||||
func correct(_ text: String) -> String
|
||||
|
||||
/// Words the engine should never flag (names, brand terms, jargon).
|
||||
var ignoreList: Set<String> { get set }
|
||||
}
|
||||
|
||||
extension GrammarEngine {
|
||||
/// Default correction: apply suggestions right-to-left so offsets stay valid.
|
||||
func correct(_ text: String) -> String {
|
||||
let ns = NSMutableString(string: text)
|
||||
for s in check(text).sorted(by: { $0.location > $1.location }) {
|
||||
guard s.location + s.length <= ns.length else { continue }
|
||||
ns.replaceCharacters(in: s.range, with: s.replacement)
|
||||
}
|
||||
return ns as String
|
||||
}
|
||||
}
|
||||
75
Sources/GrammaGem/Grammar/HarperEngine.swift
Normal file
75
Sources/GrammaGem/Grammar/HarperEngine.swift
Normal file
@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
/// STUB standing in for the real Harper core.
|
||||
///
|
||||
/// TODO(real-integration): replace the rule set below with calls into the Harper
|
||||
/// Rust crate over C-FFI (`harper_check(text) -> spans`). This stub implements a
|
||||
/// handful of Harper-style deterministic rules so the system loop, live
|
||||
/// underlines, and tests have something real to exercise. It runs in well under
|
||||
/// 10ms and is fully offline — matching Harper's performance profile.
|
||||
final class HarperEngine: GrammarEngine {
|
||||
var ignoreList: Set<String> = []
|
||||
|
||||
func check(_ text: String) -> [Suggestion] {
|
||||
var out: [Suggestion] = []
|
||||
let ns = text as NSString
|
||||
|
||||
// Rule 1 — repeated word ("the the" -> "the").
|
||||
out += matches(in: text, pattern: #"\b(\w+)\s+\1\b"#) { match in
|
||||
let full = ns.substring(with: match.range)
|
||||
let firstWord = ns.substring(with: match.range(at: 1))
|
||||
guard !ignoreList.contains(firstWord.lowercased()) else { return nil }
|
||||
return Suggestion(
|
||||
location: match.range.location, length: match.range.length,
|
||||
original: full, replacement: firstWord,
|
||||
kind: .repetition, message: "Repeated word")
|
||||
}
|
||||
|
||||
// Rule 2 — lowercase standalone "i" -> "I".
|
||||
out += matches(in: text, pattern: #"(?<=\s|^)i(?=\s|$|')"#) { match in
|
||||
Suggestion(
|
||||
location: match.range.location, length: match.range.length,
|
||||
original: "i", replacement: "I",
|
||||
kind: .capitalization, message: "Capitalize the pronoun “I”")
|
||||
}
|
||||
|
||||
// Rule 3 — a few common confusions / typos.
|
||||
let confusions: [(String, String, GrammarKind, String)] = [
|
||||
(#"\btheir\b(?=\s+(any|are|is|was|were)\b)"#, "there", .grammar, "Did you mean “there”?"),
|
||||
(#"\bteh\b"#, "the", .spelling, "Spelling"),
|
||||
(#"\brecieve\b"#, "receive", .spelling, "Spelling (i before e)"),
|
||||
(#"\bcheckin\b"#, "check in", .phrasing, "“check in” is two words"),
|
||||
(#"\blmk\b"#, "let me know", .phrasing, "Expand abbreviation"),
|
||||
(#"(?<=\s|^)u(?=\s|$)"#, "you", .phrasing, "Expand “u”"),
|
||||
]
|
||||
for (pattern, replacement, kind, message) in confusions {
|
||||
out += matches(in: text, pattern: pattern) { match in
|
||||
let original = ns.substring(with: match.range)
|
||||
guard !ignoreList.contains(original.lowercased()) else { return nil }
|
||||
return Suggestion(
|
||||
location: match.range.location, length: match.range.length,
|
||||
original: original, replacement: replacement,
|
||||
kind: kind, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 4 — sentence should start uppercase (very light heuristic).
|
||||
// (Left to the real Harper integration; intentionally omitted to avoid
|
||||
// false positives in this stub.)
|
||||
|
||||
return out.sorted { $0.location < $1.location }
|
||||
}
|
||||
|
||||
// MARK: - Regex helper
|
||||
|
||||
private func matches(
|
||||
in text: String, pattern: String,
|
||||
_ make: (NSTextCheckingResult) -> Suggestion?
|
||||
) -> [Suggestion] {
|
||||
guard let re = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
||||
return []
|
||||
}
|
||||
let full = NSRange(text.startIndex..., in: text)
|
||||
return re.matches(in: text, range: full).compactMap(make)
|
||||
}
|
||||
}
|
||||
37
Sources/GrammaGem/Grammar/PersonalDictionary.swift
Normal file
37
Sources/GrammaGem/Grammar/PersonalDictionary.swift
Normal file
@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
/// The user's personal dictionary — names, brand terms, jargon GrammaGem should
|
||||
/// never "correct". Feeds Harper's ignore-list and the LLM context. Capped at 25
|
||||
/// entries on free, unlimited on paid (enforced by `FeatureGate`, not here).
|
||||
@MainActor
|
||||
final class PersonalDictionary: ObservableObject {
|
||||
@Published private(set) var entries: [String]
|
||||
|
||||
private let key = "GrammaGem.personalDictionary"
|
||||
private let defaults = UserDefaults.standard
|
||||
|
||||
init() {
|
||||
entries = defaults.stringArray(forKey: key) ?? []
|
||||
}
|
||||
|
||||
func contains(_ word: String) -> Bool {
|
||||
entries.contains { $0.caseInsensitiveCompare(word) == .orderedSame }
|
||||
}
|
||||
|
||||
/// Append a trimmed, de-duplicated entry. Cap enforcement happens upstream.
|
||||
func add(_ word: String) {
|
||||
let w = word.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !w.isEmpty, !contains(w) else { return }
|
||||
entries.append(w)
|
||||
persist()
|
||||
}
|
||||
|
||||
func remove(_ word: String) {
|
||||
entries.removeAll { $0.caseInsensitiveCompare(word) == .orderedSame }
|
||||
persist()
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
defaults.set(entries, forKey: key)
|
||||
}
|
||||
}
|
||||
42
Sources/GrammaGem/Licensing/DeviceFingerprint.swift
Normal file
42
Sources/GrammaGem/Licensing/DeviceFingerprint.swift
Normal file
@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
import IOKit
|
||||
import CryptoKit
|
||||
|
||||
/// Derives a stable, privacy-preserving device identifier.
|
||||
///
|
||||
/// Lemon Squeezy has no hardware fingerprinting of its own, so we build one
|
||||
/// (spec §6): take the hardware `IOPlatformUUID`, SHA-256 it, and pass the hash
|
||||
/// as the `instance_name` on activation. Same Mac → same instance → a reinstall
|
||||
/// doesn't consume a second device slot. The raw UUID never leaves the device;
|
||||
/// only its hash is sent.
|
||||
enum DeviceFingerprint {
|
||||
/// SHA-256 of the hardware platform UUID, hex-encoded. Falls back to a
|
||||
/// persisted random ID if IOKit is somehow unavailable.
|
||||
static func stableID() -> String {
|
||||
if let uuid = platformUUID() {
|
||||
let digest = SHA256.hash(data: Data(uuid.utf8))
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
return fallbackID()
|
||||
}
|
||||
|
||||
private static func platformUUID() -> String? {
|
||||
let service = IOServiceGetMatchingService(
|
||||
kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice"))
|
||||
guard service != 0 else { return nil }
|
||||
defer { IOObjectRelease(service) }
|
||||
|
||||
guard let cf = IORegistryEntryCreateCFProperty(
|
||||
service, kIOPlatformUUIDKey as CFString, kCFAllocatorDefault, 0)
|
||||
else { return nil }
|
||||
return cf.takeRetainedValue() as? String
|
||||
}
|
||||
|
||||
private static func fallbackID() -> String {
|
||||
let key = "GrammaGem.fallbackDeviceID"
|
||||
if let existing = UserDefaults.standard.string(forKey: key) { return existing }
|
||||
let id = UUID().uuidString
|
||||
UserDefaults.standard.set(id, forKey: key)
|
||||
return id
|
||||
}
|
||||
}
|
||||
58
Sources/GrammaGem/Licensing/Keychain.swift
Normal file
58
Sources/GrammaGem/Licensing/Keychain.swift
Normal file
@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Minimal Keychain wrapper for the license record. The license key + instance
|
||||
/// id + last-validated timestamp live here (never in plain UserDefaults).
|
||||
enum Keychain {
|
||||
private static let service = AppConfig.bundleIdentifier
|
||||
|
||||
static func set(_ data: Data, account: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
|
||||
var add = query
|
||||
add[kSecValueData as String] = data
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
let status = SecItemAdd(add as CFDictionary, nil)
|
||||
if status != errSecSuccess {
|
||||
Log.licensing.error("Keychain set failed: \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
static func get(account: String) -> Data? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess else { return nil }
|
||||
return result as? Data
|
||||
}
|
||||
|
||||
static func delete(account: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
|
||||
// Codable convenience.
|
||||
static func setObject<T: Encodable>(_ value: T, account: String) {
|
||||
if let data = try? JSONEncoder().encode(value) { set(data, account: account) }
|
||||
}
|
||||
|
||||
static func getObject<T: Decodable>(_ type: T.Type, account: String) -> T? {
|
||||
guard let data = get(account: account) else { return nil }
|
||||
return try? JSONDecoder().decode(type, from: data)
|
||||
}
|
||||
}
|
||||
125
Sources/GrammaGem/Licensing/LemonSqueezyClient.swift
Normal file
125
Sources/GrammaGem/Licensing/LemonSqueezyClient.swift
Normal file
@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
|
||||
/// Talks to the **Lemon Squeezy License API** directly from the app — no server
|
||||
/// of ours, no database, no per-user cost (spec §6). Exposes activate / validate
|
||||
/// / deactivate and hard-verifies that a key belongs to GrammaGem + the claimed tier.
|
||||
final class LemonSqueezyClient {
|
||||
private let session: URLSession
|
||||
private let base = AppConfig.LemonSqueezy.apiBase
|
||||
|
||||
init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
// MARK: - Endpoints
|
||||
|
||||
func activate(key: String, instanceName: String) async throws -> (instanceID: String, tier: Tier) {
|
||||
let res: LSActivateResponse = try await post(
|
||||
"licenses/activate",
|
||||
params: ["license_key": key, "instance_name": instanceName])
|
||||
|
||||
if let err = res.error { throw mapServerError(err) }
|
||||
guard res.activated, let instance = res.instance else {
|
||||
throw LicenseError.serverMessage("Activation failed.")
|
||||
}
|
||||
let tier = try verifyAndResolveTier(meta: res.meta, licenseKey: res.license_key)
|
||||
return (instance.id, tier)
|
||||
}
|
||||
|
||||
func validate(key: String, instanceID: String?) async throws -> (valid: Bool, tier: Tier) {
|
||||
var params = ["license_key": key]
|
||||
if let instanceID { params["instance_id"] = instanceID }
|
||||
let res: LSValidateResponse = try await post("licenses/validate", params: params)
|
||||
|
||||
if let err = res.error { throw mapServerError(err) }
|
||||
let tier = try verifyAndResolveTier(meta: res.meta, licenseKey: res.license_key)
|
||||
return (res.valid, tier)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func deactivate(key: String, instanceID: String) async throws -> Bool {
|
||||
let res: LSDeactivateResponse = try await post(
|
||||
"licenses/deactivate",
|
||||
params: ["license_key": key, "instance_id": instanceID])
|
||||
if let err = res.error { throw mapServerError(err) }
|
||||
return res.deactivated
|
||||
}
|
||||
|
||||
// MARK: - Hard verification (spec §6)
|
||||
|
||||
/// Confirm the key is for OUR store/product, then resolve which tier its
|
||||
/// variant corresponds to. Prevents a key from another LS product unlocking GrammaGem.
|
||||
private func verifyAndResolveTier(meta: LSMeta?, licenseKey: LSLicenseKey?) throws -> Tier {
|
||||
guard let meta else { throw LicenseError.wrongProduct }
|
||||
|
||||
// Store + product check. If AppConfig still holds placeholder (non-numeric)
|
||||
// IDs, we log and skip the check so the stub flow runs — TODO(real-integration):
|
||||
// once real numeric IDs are set, this becomes a hard gate.
|
||||
if let expectedStore = Int(AppConfig.LemonSqueezy.expectedStoreID),
|
||||
let expectedProduct = Int(AppConfig.LemonSqueezy.expectedProductID) {
|
||||
guard meta.store_id == expectedStore, meta.product_id == expectedProduct else {
|
||||
throw LicenseError.wrongProduct
|
||||
}
|
||||
} else {
|
||||
Log.licensing.warning("Skipping store/product hard-verify — placeholder IDs in AppConfig.")
|
||||
}
|
||||
|
||||
// Resolve tier from the variant id (reverse-lookup the configured map).
|
||||
if let variantID = meta.variant_id {
|
||||
for (tier, idString) in AppConfig.LemonSqueezy.variantID {
|
||||
if Int(idString) == variantID { return tier }
|
||||
}
|
||||
}
|
||||
// Fall back to whatever the variant name hints at, else Solo.
|
||||
switch meta.variant_name?.lowercased() {
|
||||
case let v? where v.contains("studio"): return .studio
|
||||
case let v? where v.contains("personal"): return .personal
|
||||
default: return .solo
|
||||
}
|
||||
}
|
||||
|
||||
private func mapServerError(_ message: String) -> LicenseError {
|
||||
let m = message.lowercased()
|
||||
if m.contains("activation limit") {
|
||||
return .activationLimitReached(0)
|
||||
}
|
||||
if m.contains("not found") || m.contains("invalid") {
|
||||
return .invalidKey
|
||||
}
|
||||
return .serverMessage(message)
|
||||
}
|
||||
|
||||
// MARK: - Transport
|
||||
|
||||
private func post<T: Decodable>(_ path: String, params: [String: String]) async throws -> T {
|
||||
var request = URLRequest(url: base.appendingPathComponent(path))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.httpBody = formEncode(params).data(using: .utf8)
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw LicenseError.network("No HTTP response")
|
||||
}
|
||||
// LS returns 200 + {activated/valid:false} for soft failures, and 4xx
|
||||
// with a JSON `error`. Try to decode the body either way.
|
||||
if let decoded = try? JSONDecoder().decode(T.self, from: data) {
|
||||
return decoded
|
||||
}
|
||||
throw LicenseError.network("Unexpected response (\(http.statusCode))")
|
||||
} catch let e as LicenseError {
|
||||
throw e
|
||||
} catch {
|
||||
throw LicenseError.network(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func formEncode(_ params: [String: String]) -> String {
|
||||
params.map { key, value in
|
||||
let v = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value
|
||||
return "\(key)=\(v)"
|
||||
}.joined(separator: "&")
|
||||
}
|
||||
}
|
||||
100
Sources/GrammaGem/Licensing/LicenseManager.swift
Normal file
100
Sources/GrammaGem/Licensing/LicenseManager.swift
Normal file
@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
|
||||
/// Owns license state and maps it to an `Entitlements` value the rest of the app
|
||||
/// gates on. Implements the two gaps Lemon Squeezy leaves to us (spec §6):
|
||||
/// device fingerprinting (via `DeviceFingerprint`) and **offline grace**.
|
||||
@MainActor
|
||||
final class LicenseManager: ObservableObject {
|
||||
@Published private(set) var record: LicenseRecord?
|
||||
@Published private(set) var lastError: String?
|
||||
@Published private(set) var isWorking = false
|
||||
|
||||
private let client: LemonSqueezyClient
|
||||
private let account = "license"
|
||||
|
||||
init(client: LemonSqueezyClient = LemonSqueezyClient()) {
|
||||
self.client = client
|
||||
self.record = Keychain.getObject(LicenseRecord.self, account: account)
|
||||
}
|
||||
|
||||
/// Effective tier *right now*, honoring the 30-day offline grace: if the
|
||||
/// device hasn't validated within the window, downgrade to Free rather than
|
||||
/// hard-lock (writing features never need the network anyway).
|
||||
var tier: Tier {
|
||||
guard let record else { return .free }
|
||||
let grace = TimeInterval(AppConfig.Limits.offlineGraceDays * 86_400)
|
||||
if Date().timeIntervalSince(record.lastValidated) > grace {
|
||||
return .free
|
||||
}
|
||||
return record.tier
|
||||
}
|
||||
|
||||
var isLicensed: Bool { tier.isPaid }
|
||||
|
||||
var entitlements: Entitlements { Entitlements(tier: tier) }
|
||||
|
||||
// MARK: - Flows
|
||||
|
||||
/// Activate a pasted key against this device.
|
||||
func activate(key: String) async {
|
||||
let key = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { lastError = "Enter a license key."; return }
|
||||
isWorking = true; lastError = nil
|
||||
defer { isWorking = false }
|
||||
|
||||
do {
|
||||
let instanceName = DeviceFingerprint.stableID()
|
||||
let (instanceID, tier) = try await client.activate(key: key, instanceName: instanceName)
|
||||
let rec = LicenseRecord(
|
||||
key: key, instanceID: instanceID, tier: tier,
|
||||
lastValidated: Date(), activatedDevices: tier.deviceCap)
|
||||
Keychain.setObject(rec, account: account)
|
||||
record = rec
|
||||
Log.licensing.info("Activated tier \(tier.rawValue, privacy: .public)")
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// On launch + every ~7 days. Network failures are non-fatal (offline grace).
|
||||
func validateIfNeeded(force: Bool = false) async {
|
||||
guard var rec = record else { return }
|
||||
let interval = TimeInterval(AppConfig.Limits.validationIntervalDays * 86_400)
|
||||
guard force || Date().timeIntervalSince(rec.lastValidated) > interval else { return }
|
||||
|
||||
do {
|
||||
let (valid, tier) = try await client.validate(key: rec.key, instanceID: rec.instanceID)
|
||||
if valid {
|
||||
rec.lastValidated = Date()
|
||||
rec.tier = tier
|
||||
Keychain.setObject(rec, account: account)
|
||||
record = rec
|
||||
} else {
|
||||
// Explicitly invalid (refunded / disabled) → drop the license.
|
||||
Log.licensing.notice("License reported invalid by server; clearing.")
|
||||
clearLocal()
|
||||
}
|
||||
} catch {
|
||||
// Offline / transient: keep the cached license under the grace window.
|
||||
Log.licensing.notice("Validation deferred (offline). Cached license stands under grace.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Free a device slot so the license can move to another Mac.
|
||||
func deactivateThisDevice() async {
|
||||
guard let rec = record else { return }
|
||||
isWorking = true; lastError = nil
|
||||
defer { isWorking = false }
|
||||
do {
|
||||
_ = try await client.deactivate(key: rec.key, instanceID: rec.instanceID)
|
||||
clearLocal()
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func clearLocal() {
|
||||
Keychain.delete(account: account)
|
||||
record = nil
|
||||
}
|
||||
}
|
||||
70
Sources/GrammaGem/Licensing/LicenseModels.swift
Normal file
70
Sources/GrammaGem/Licensing/LicenseModels.swift
Normal file
@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
|
||||
/// The locally-stored license record (Keychain-persisted).
|
||||
struct LicenseRecord: Codable, Equatable {
|
||||
var key: String
|
||||
var instanceID: String // Lemon Squeezy activation instance id
|
||||
var tier: Tier
|
||||
var lastValidated: Date
|
||||
var activatedDevices: Int
|
||||
}
|
||||
|
||||
// MARK: - Lemon Squeezy API response shapes (subset we rely on)
|
||||
|
||||
struct LSActivateResponse: Codable {
|
||||
let activated: Bool
|
||||
let instance: LSInstance?
|
||||
let license_key: LSLicenseKey?
|
||||
let meta: LSMeta?
|
||||
let error: String?
|
||||
}
|
||||
|
||||
struct LSValidateResponse: Codable {
|
||||
let valid: Bool
|
||||
let license_key: LSLicenseKey?
|
||||
let meta: LSMeta?
|
||||
let error: String?
|
||||
}
|
||||
|
||||
struct LSDeactivateResponse: Codable {
|
||||
let deactivated: Bool
|
||||
let error: String?
|
||||
}
|
||||
|
||||
struct LSInstance: Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
struct LSLicenseKey: Codable {
|
||||
let status: String // "active", "expired", "disabled", …
|
||||
let activation_limit: Int?
|
||||
let activation_usage: Int?
|
||||
}
|
||||
|
||||
/// `meta` carries the store/product/variant identity we *hard-verify* against
|
||||
/// AppConfig so a key from another Lemon Squeezy product can't unlock GrammaGem.
|
||||
struct LSMeta: Codable {
|
||||
let store_id: Int?
|
||||
let product_id: Int?
|
||||
let variant_id: Int?
|
||||
let variant_name: String?
|
||||
}
|
||||
|
||||
enum LicenseError: LocalizedError, Equatable {
|
||||
case network(String)
|
||||
case invalidKey
|
||||
case wrongProduct
|
||||
case activationLimitReached(Int)
|
||||
case serverMessage(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .network(let m): return "Couldn't reach the license server: \(m)"
|
||||
case .invalidKey: return "That license key isn't valid."
|
||||
case .wrongProduct: return "That key belongs to a different product."
|
||||
case .activationLimitReached(let n): return "This plan is active on \(n) Macs — deactivate one or upgrade."
|
||||
case .serverMessage(let m): return m
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Sources/GrammaGem/System/AppDetector.swift
Normal file
26
Sources/GrammaGem/System/AppDetector.swift
Normal file
@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
/// Reports the frontmost application so App-Aware mode switching can pick the
|
||||
/// right Writing Mode. Pure local introspection — no network, no logging of content.
|
||||
@MainActor
|
||||
final class AppDetector {
|
||||
struct FrontApp: Equatable {
|
||||
let bundleID: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
/// The app currently in the foreground (the one the hotkey will act on).
|
||||
func frontmost() -> FrontApp? {
|
||||
guard let app = NSWorkspace.shared.frontmostApplication else { return nil }
|
||||
return FrontApp(
|
||||
bundleID: app.bundleIdentifier ?? "unknown",
|
||||
name: app.localizedName ?? "App")
|
||||
}
|
||||
|
||||
/// The Writing Mode App-Aware would auto-apply for the current frontmost app.
|
||||
func suggestedMode() -> WritingMode? {
|
||||
guard let front = frontmost() else { return nil }
|
||||
return ModeRegistry.mode(forBundleID: front.bundleID)
|
||||
}
|
||||
}
|
||||
82
Sources/GrammaGem/System/HotkeyManager.swift
Normal file
82
Sources/GrammaGem/System/HotkeyManager.swift
Normal file
@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
import Carbon.HIToolbox
|
||||
|
||||
/// Registers system-wide global hotkeys via Carbon `RegisterEventHotKey`.
|
||||
///
|
||||
/// This is the no-dependency path. The spec lists `KeyboardShortcuts`
|
||||
/// (Sindre Sorhus) for a nicer recorder UI; you can swap this implementation
|
||||
/// for that package without touching callers. User-configurable (defaults ⌘; and ⌘').
|
||||
final class HotkeyManager {
|
||||
/// Logical actions a hotkey can fire.
|
||||
enum Action { case fix, ask }
|
||||
|
||||
var onAction: ((Action) -> Void)?
|
||||
|
||||
private var refs: [EventHotKeyRef?] = []
|
||||
private var handler: EventHandlerRef?
|
||||
private var idToAction: [UInt32: Action] = [:]
|
||||
|
||||
init() {
|
||||
installHandler()
|
||||
}
|
||||
|
||||
deinit {
|
||||
unregisterAll()
|
||||
if let handler { RemoveEventHandler(handler) }
|
||||
}
|
||||
|
||||
/// Register the two product hotkeys. Call again to re-register after the
|
||||
/// user changes a shortcut.
|
||||
func registerDefaults() {
|
||||
register(id: 1, keyCode: AppConfig.Hotkey.defaultKeyCode,
|
||||
modifiers: AppConfig.Hotkey.defaultModifiers, action: .fix)
|
||||
register(id: 2, keyCode: AppConfig.Hotkey.askKeyCode,
|
||||
modifiers: AppConfig.Hotkey.askModifiers, action: .ask)
|
||||
}
|
||||
|
||||
func register(id: UInt32, keyCode: UInt32, modifiers: UInt32, action: Action) {
|
||||
idToAction[id] = action
|
||||
let hotKeyID = EventHotKeyID(signature: OSType(0x47474D31), id: id) // 'GGM1'
|
||||
var ref: EventHotKeyRef?
|
||||
let status = RegisterEventHotKey(
|
||||
keyCode, modifiers, hotKeyID,
|
||||
GetEventDispatcherTarget(), 0, &ref)
|
||||
if status == noErr {
|
||||
refs.append(ref)
|
||||
} else {
|
||||
Log.system.error("RegisterEventHotKey failed: \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
func unregisterAll() {
|
||||
for ref in refs where ref != nil { UnregisterEventHotKey(ref) }
|
||||
refs.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Carbon event plumbing
|
||||
|
||||
private func installHandler() {
|
||||
var spec = EventTypeSpec(
|
||||
eventClass: OSType(kEventClassKeyboard),
|
||||
eventKind: UInt32(kEventHotKeyPressed))
|
||||
let selfPtr = Unmanaged.passUnretained(self).toOpaque()
|
||||
|
||||
InstallEventHandler(
|
||||
GetEventDispatcherTarget(),
|
||||
{ (_, eventRef, userData) -> OSStatus in
|
||||
guard let eventRef, let userData else { return noErr }
|
||||
var hkID = EventHotKeyID()
|
||||
let err = GetEventParameter(
|
||||
eventRef, EventParamName(kEventParamDirectObject),
|
||||
EventParamType(typeEventHotKeyID), nil,
|
||||
MemoryLayout<EventHotKeyID>.size, nil, &hkID)
|
||||
guard err == noErr else { return err }
|
||||
let mgr = Unmanaged<HotkeyManager>.fromOpaque(userData).takeUnretainedValue()
|
||||
if let action = mgr.idToAction[hkID.id] {
|
||||
DispatchQueue.main.async { mgr.onAction?(action) }
|
||||
}
|
||||
return noErr
|
||||
},
|
||||
1, &spec, selfPtr, &handler)
|
||||
}
|
||||
}
|
||||
54
Sources/GrammaGem/System/Permissions.swift
Normal file
54
Sources/GrammaGem/System/Permissions.swift
Normal file
@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
|
||||
/// Tracks and requests the macOS permissions GrammaGem needs to read & replace
|
||||
/// text system-wide: **Accessibility** (required) and, for the synthetic-keystroke
|
||||
/// clipboard fallback, **Input Monitoring** may also be prompted by the system.
|
||||
@MainActor
|
||||
final class Permissions: ObservableObject {
|
||||
@Published private(set) var accessibilityTrusted: Bool = false
|
||||
|
||||
init() {
|
||||
refresh()
|
||||
}
|
||||
|
||||
/// True when the app is trusted for the Accessibility API.
|
||||
func refresh() {
|
||||
accessibilityTrusted = AXIsProcessTrusted()
|
||||
}
|
||||
|
||||
/// Prompt for Accessibility (shows the system dialog the first time).
|
||||
func requestAccessibility() {
|
||||
let key = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
|
||||
let options = [key: true] as CFDictionary
|
||||
accessibilityTrusted = AXIsProcessTrustedWithOptions(options)
|
||||
}
|
||||
|
||||
/// Deep-link to the relevant System Settings pane.
|
||||
func openAccessibilitySettings() {
|
||||
open("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
|
||||
}
|
||||
|
||||
func openInputMonitoringSettings() {
|
||||
open("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent")
|
||||
}
|
||||
|
||||
private func open(_ urlString: String) {
|
||||
guard let url = URL(string: urlString) else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
/// Poll until the user grants Accessibility (used by onboarding to advance
|
||||
/// automatically once permission flips on). Cancels when `accessibilityTrusted`.
|
||||
func startPollingUntilGranted(interval: TimeInterval = 1.0) {
|
||||
guard !accessibilityTrusted else { return }
|
||||
Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] timer in
|
||||
Task { @MainActor in
|
||||
guard let self else { timer.invalidate(); return }
|
||||
self.refresh()
|
||||
if self.accessibilityTrusted { timer.invalidate() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
136
Sources/GrammaGem/System/TextCapture.swift
Normal file
136
Sources/GrammaGem/System/TextCapture.swift
Normal file
@ -0,0 +1,136 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import Carbon.HIToolbox
|
||||
|
||||
/// Reads the user's selected text from the frontmost app and writes a corrected
|
||||
/// value back — the mechanism that makes GrammaGem work *everywhere*.
|
||||
///
|
||||
/// Two paths, mirroring the spec (§4):
|
||||
/// 1. **Accessibility API** (preferred): read/write `kAXSelectedTextAttribute`
|
||||
/// on the focused element. Works in most native + many Electron apps.
|
||||
/// 2. **Clipboard fallback** (universal): synthesize ⌘C, read the pasteboard,
|
||||
/// write the result back, synthesize ⌘V, then restore the prior clipboard.
|
||||
final class TextCapture {
|
||||
enum Method { case accessibility, clipboard }
|
||||
|
||||
struct Capture {
|
||||
let text: String
|
||||
let method: Method
|
||||
/// Held so the AX path can set the new value on the same element.
|
||||
let focusedElement: AXUIElement?
|
||||
}
|
||||
|
||||
enum CaptureError: LocalizedError {
|
||||
case noSelection
|
||||
case notPermitted
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noSelection: return "Select some text first, then press the hotkey."
|
||||
case .notPermitted: return "GrammaGem needs Accessibility permission to read and replace text."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let pasteboard = NSPasteboard.general
|
||||
|
||||
// MARK: - Capture
|
||||
|
||||
/// Try AX first; on empty/unavailable, fall back to the clipboard path.
|
||||
func capture() throws -> Capture {
|
||||
if let ax = captureViaAccessibility() {
|
||||
return ax
|
||||
}
|
||||
if let clip = try captureViaClipboard() {
|
||||
return clip
|
||||
}
|
||||
throw CaptureError.noSelection
|
||||
}
|
||||
|
||||
private func captureViaAccessibility() -> Capture? {
|
||||
guard AXIsProcessTrusted() else { return nil }
|
||||
let systemWide = AXUIElementCreateSystemWide()
|
||||
|
||||
var focusedRef: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(
|
||||
systemWide, kAXFocusedUIElementAttribute as CFString, &focusedRef) == .success,
|
||||
let focusedRef
|
||||
else { return nil }
|
||||
// Force-cast through CFTypeRef to AXUIElement (same CF type).
|
||||
let focused = focusedRef as! AXUIElement
|
||||
|
||||
var valueRef: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(
|
||||
focused, kAXSelectedTextAttribute as CFString, &valueRef) == .success,
|
||||
let text = valueRef as? String, !text.isEmpty
|
||||
else { return nil }
|
||||
|
||||
return Capture(text: text, method: .accessibility, focusedElement: focused)
|
||||
}
|
||||
|
||||
private func captureViaClipboard() throws -> Capture? {
|
||||
guard AXIsProcessTrusted() else { throw CaptureError.notPermitted }
|
||||
let saved = pasteboard.string(forType: .string)
|
||||
let beforeChange = pasteboard.changeCount
|
||||
|
||||
sendCommandKey(CGKeyCode(kVK_ANSI_C))
|
||||
// Give the frontmost app a moment to put the selection on the pasteboard.
|
||||
Thread.sleep(forTimeInterval: 0.12)
|
||||
|
||||
guard pasteboard.changeCount != beforeChange,
|
||||
let copied = pasteboard.string(forType: .string), !copied.isEmpty
|
||||
else {
|
||||
// Restore and report nothing selected.
|
||||
if let saved { writeClipboard(saved) }
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stash the saved clipboard on the capture so replace() can restore it.
|
||||
savedClipboard = saved
|
||||
return Capture(text: copied, method: .clipboard, focusedElement: nil)
|
||||
}
|
||||
|
||||
// MARK: - Replace
|
||||
|
||||
@discardableResult
|
||||
func replace(_ newText: String, capture: Capture) -> Bool {
|
||||
switch capture.method {
|
||||
case .accessibility:
|
||||
guard let element = capture.focusedElement else { return false }
|
||||
let status = AXUIElementSetAttributeValue(
|
||||
element, kAXSelectedTextAttribute as CFString, newText as CFTypeRef)
|
||||
return status == .success
|
||||
case .clipboard:
|
||||
writeClipboard(newText)
|
||||
sendCommandKey(CGKeyCode(kVK_ANSI_V))
|
||||
Thread.sleep(forTimeInterval: 0.10)
|
||||
// Restore the user's previous clipboard contents.
|
||||
if let saved = savedClipboard {
|
||||
Thread.sleep(forTimeInterval: 0.05)
|
||||
writeClipboard(saved)
|
||||
}
|
||||
savedClipboard = nil
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var savedClipboard: String?
|
||||
|
||||
private func writeClipboard(_ text: String) {
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(text, forType: .string)
|
||||
}
|
||||
|
||||
/// Synthesize a ⌘<key> keystroke via CGEvent (needs Accessibility / Input Monitoring).
|
||||
private func sendCommandKey(_ key: CGKeyCode) {
|
||||
let source = CGEventSource(stateID: .combinedSessionState)
|
||||
let down = CGEvent(keyboardEventSource: source, virtualKey: key, keyDown: true)
|
||||
let up = CGEvent(keyboardEventSource: source, virtualKey: key, keyDown: false)
|
||||
down?.flags = .maskCommand
|
||||
up?.flags = .maskCommand
|
||||
down?.post(tap: .cghidEventTap)
|
||||
up?.post(tap: .cghidEventTap)
|
||||
}
|
||||
}
|
||||
109
Sources/GrammaGem/System/TextReplacementCoordinator.swift
Normal file
109
Sources/GrammaGem/System/TextReplacementCoordinator.swift
Normal file
@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
|
||||
/// Orchestrates the end-to-end system-wide loop:
|
||||
/// capture selected text → gate by entitlement → run an engine → write it back.
|
||||
///
|
||||
/// `fix` uses Layer 1 (Harper grammar — free, unlimited, instant).
|
||||
/// `ask` and Mode rewrites use Layer 2 (local LLM — gated + counted on free tier).
|
||||
@MainActor
|
||||
final class TextReplacementCoordinator {
|
||||
private let capture: TextCapture
|
||||
private let grammar: GrammarEngine
|
||||
private let ai: AIEngine
|
||||
private let gate: FeatureGate
|
||||
private let detector: AppDetector
|
||||
|
||||
init(capture: TextCapture, grammar: GrammarEngine, ai: AIEngine,
|
||||
gate: FeatureGate, detector: AppDetector) {
|
||||
self.capture = capture
|
||||
self.grammar = grammar
|
||||
self.ai = ai
|
||||
self.gate = gate
|
||||
self.detector = detector
|
||||
}
|
||||
|
||||
/// The "fix" hotkey — instant grammar correction (Layer 1, always free).
|
||||
func handleFix() async -> ProcessOutcome {
|
||||
let cap: TextCapture.Capture
|
||||
do {
|
||||
cap = try await captureOffMain()
|
||||
} catch TextCapture.CaptureError.noSelection {
|
||||
return .noSelection
|
||||
} catch {
|
||||
return .failed(error.localizedDescription)
|
||||
}
|
||||
|
||||
let corrected = grammar.correct(cap.text)
|
||||
guard corrected != cap.text else { return .replaced(corrected) }
|
||||
|
||||
let ok = await replaceOffMain(corrected, cap)
|
||||
return ok ? .replaced(corrected) : .failed("Couldn't write the correction back.")
|
||||
}
|
||||
|
||||
/// The "Ask" hotkey/popover — a local-LLM instruction over the selection.
|
||||
func handleAsk(_ instruction: String) async -> ProcessOutcome {
|
||||
let action = AIAction.ask(instruction)
|
||||
if case .denied(let reason) = gate.authorizeAI(action) {
|
||||
return .blockedByEntitlement(reason)
|
||||
}
|
||||
guard ai.isReady else { return .failed(AIEngineError.modelNotReady.localizedDescription) }
|
||||
|
||||
let cap: TextCapture.Capture
|
||||
do { cap = try await captureOffMain() } catch { return .failed(error.localizedDescription) }
|
||||
|
||||
do {
|
||||
let result = try await ai.run(action, on: cap.text)
|
||||
gate.recordAIActionUsed()
|
||||
let ok = await replaceOffMain(result, cap)
|
||||
return ok ? .replaced(result) : .failed("Couldn't write the result back.")
|
||||
} catch {
|
||||
return .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a Writing Mode rewrite to the selection (paid; free tier only Polish).
|
||||
func handleApplyMode(_ mode: WritingMode) async -> ProcessOutcome {
|
||||
let action = AIAction.applyMode(mode)
|
||||
if case .denied(let reason) = gate.authorizeAI(action) {
|
||||
return .blockedByEntitlement(reason)
|
||||
}
|
||||
guard ai.isReady else { return .failed(AIEngineError.modelNotReady.localizedDescription) }
|
||||
|
||||
let cap: TextCapture.Capture
|
||||
do { cap = try await captureOffMain() } catch { return .failed(error.localizedDescription) }
|
||||
|
||||
do {
|
||||
let result = try await ai.run(action, on: cap.text)
|
||||
gate.recordAIActionUsed()
|
||||
let ok = await replaceOffMain(result, cap)
|
||||
return ok ? .replaced(result) : .failed("Couldn't write the result back.")
|
||||
} catch {
|
||||
return .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Milestone-1 proof: capture → UPPERCASE → replace, end-to-end, no engine.
|
||||
func handleDebugUppercase() async -> ProcessOutcome {
|
||||
let cap: TextCapture.Capture
|
||||
do { cap = try await captureOffMain() } catch { return .failed(error.localizedDescription) }
|
||||
let out = cap.text.uppercased()
|
||||
let ok = await replaceOffMain(out, cap)
|
||||
return ok ? .replaced(out) : .failed("Replace failed.")
|
||||
}
|
||||
|
||||
// MARK: - Off-main wrappers (capture/replace block on synthetic keystrokes)
|
||||
|
||||
private func captureOffMain() async throws -> TextCapture.Capture {
|
||||
let capture = self.capture
|
||||
return try await Task.detached(priority: .userInitiated) {
|
||||
try capture.capture()
|
||||
}.value
|
||||
}
|
||||
|
||||
private func replaceOffMain(_ text: String, _ cap: TextCapture.Capture) async -> Bool {
|
||||
let capture = self.capture
|
||||
return await Task.detached(priority: .userInitiated) {
|
||||
capture.replace(text, capture: cap)
|
||||
}.value
|
||||
}
|
||||
}
|
||||
78
Tests/GrammaGemTests/GrammaGemTests.swift
Normal file
78
Tests/GrammaGemTests/GrammaGemTests.swift
Normal file
@ -0,0 +1,78 @@
|
||||
import XCTest
|
||||
@testable import GrammaGem
|
||||
|
||||
final class GrammaGemTests: XCTestCase {
|
||||
|
||||
// MARK: - Tiers / entitlements
|
||||
|
||||
func testDeviceCapsMatchSpec() {
|
||||
XCTAssertEqual(Tier.solo.deviceCap, 1)
|
||||
XCTAssertEqual(Tier.personal.deviceCap, 2)
|
||||
XCTAssertEqual(Tier.studio.deviceCap, 4)
|
||||
XCTAssertEqual(Tier.free.deviceCap, 1)
|
||||
}
|
||||
|
||||
func testFreeEntitlementsAreLimited() {
|
||||
let free = Entitlements(tier: .free)
|
||||
XCTAssertFalse(free.unlimitedAIActions)
|
||||
XCTAssertEqual(free.dailyAIActionCap, AppConfig.Limits.freeDailyAIActions)
|
||||
XCTAssertEqual(free.dictionaryCap, AppConfig.Limits.freeDictionaryCap)
|
||||
XCTAssertFalse(free.appAwareSwitching)
|
||||
XCTAssertFalse(free.allModes)
|
||||
XCTAssertTrue(free.unlimitedGrammar) // grammar is always free
|
||||
}
|
||||
|
||||
func testPaidEntitlementsUnlockEverything() {
|
||||
let paid = Entitlements(tier: .personal)
|
||||
XCTAssertTrue(paid.unlimitedAIActions)
|
||||
XCTAssertEqual(paid.dailyAIActionCap, Int.max)
|
||||
XCTAssertEqual(paid.dictionaryCap, Int.max)
|
||||
XCTAssertTrue(paid.appAwareSwitching)
|
||||
XCTAssertTrue(paid.allModes)
|
||||
}
|
||||
|
||||
// MARK: - Grammar (Harper stub) rules
|
||||
|
||||
func testHarperFixesRepeatedWordAndCasing() {
|
||||
let harper = HarperEngine()
|
||||
let input = "hey i wanted to checkin about the the proposal"
|
||||
let fixed = harper.correct(input)
|
||||
XCTAssertFalse(fixed.contains("the the"), "repeated word should be collapsed")
|
||||
XCTAssertTrue(fixed.contains(" I "), "lowercase pronoun i should be capitalized")
|
||||
XCTAssertTrue(fixed.contains("check in"), "checkin should split into two words")
|
||||
}
|
||||
|
||||
func testIgnoreListSuppressesCorrection() {
|
||||
let harper = HarperEngine()
|
||||
harper.ignoreList = ["teh"] // pretend "teh" is an intentional brand term
|
||||
let suggestions = harper.check("teh launch")
|
||||
XCTAssertFalse(suggestions.contains { $0.original.lowercased() == "teh" })
|
||||
}
|
||||
|
||||
// MARK: - Modes
|
||||
|
||||
func testFreeTierGetsOnlyPolishMode() {
|
||||
let free = ModeRegistry.available(for: .free)
|
||||
XCTAssertEqual(free.map(\.id), ["polish"])
|
||||
let paid = ModeRegistry.available(for: .studio)
|
||||
XCTAssertGreaterThan(paid.count, 1)
|
||||
XCTAssertTrue(paid.contains { $0.id == "email" })
|
||||
}
|
||||
|
||||
func testAppAwareModeMapping() {
|
||||
XCTAssertEqual(ModeRegistry.mode(forBundleID: "com.apple.mail")?.id, "email")
|
||||
XCTAssertEqual(ModeRegistry.mode(forBundleID: "com.microsoft.VSCode")?.id, "code")
|
||||
XCTAssertNil(ModeRegistry.mode(forBundleID: "com.unknown.app"))
|
||||
}
|
||||
|
||||
// MARK: - Device fingerprint
|
||||
|
||||
func testDeviceFingerprintIsStableAndHashed() {
|
||||
let a = DeviceFingerprint.stableID()
|
||||
let b = DeviceFingerprint.stableID()
|
||||
XCTAssertEqual(a, b, "fingerprint must be stable across calls")
|
||||
XCTAssertFalse(a.isEmpty)
|
||||
// SHA-256 hex is 64 chars (unless the random fallback path is hit).
|
||||
XCTAssertTrue(a.count == 64 || a.count == UUID().uuidString.count)
|
||||
}
|
||||
}
|
||||
47
harper-ffi/README.md
Normal file
47
harper-ffi/README.md
Normal file
@ -0,0 +1,47 @@
|
||||
# harper-ffi
|
||||
|
||||
A thin Rust static library that exposes the **Harper** grammar core
|
||||
(Apache-2.0) to Swift over a C-FFI. This directory is a placeholder for the real
|
||||
integration (spec §1, §2, §8).
|
||||
|
||||
The Swift side talks to this through the `GrammarEngine` protocol; today
|
||||
[`HarperEngine.swift`](../Sources/GrammaGem/Grammar/HarperEngine.swift) ships a
|
||||
small pure-Swift rule stub so the system loop + tests run with zero native deps.
|
||||
|
||||
## Planned shape
|
||||
|
||||
```
|
||||
harper-ffi/
|
||||
├─ Cargo.toml # crate-type = ["staticlib"]; depends on `harper-core`
|
||||
├─ src/lib.rs # #[no_mangle] extern "C" fns over harper-core
|
||||
└─ include/harper.h # C header consumed by Swift (module map / bridging)
|
||||
```
|
||||
|
||||
Sketch of the FFI surface:
|
||||
|
||||
```rust
|
||||
// src/lib.rs
|
||||
#[no_mangle]
|
||||
pub extern "C" fn harper_check(text: *const c_char,
|
||||
out_json: *mut *mut c_char) -> usize { /* … */ }
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn harper_free(ptr: *mut c_char) { /* … */ }
|
||||
```
|
||||
|
||||
## Wiring into SwiftPM
|
||||
|
||||
1. Build the static lib: `cargo build --release` → `libharper_ffi.a`.
|
||||
2. Add a `systemLibrary`/binary target (or `linkerSettings`/`unsafeFlags`) in
|
||||
`Package.swift` pointing at the `.a` + the header's module map.
|
||||
3. Replace the rule set in `HarperEngine.check(_:)` with calls to
|
||||
`harper_check`, decoding the returned span JSON into `[Suggestion]`.
|
||||
|
||||
> Alternative considered: bundle `harper-ls` and talk to it as a subprocess over
|
||||
> LSP. The FFI route is leaner (no process, no JSON-RPC) and is preferred.
|
||||
|
||||
## Why Harper (not LanguageTool)
|
||||
|
||||
Harper is ~1/50th the memory of LanguageTool and needs no multi-GB n-gram data,
|
||||
so it's the right *embeddable* core. LanguageTool stays an optional power-user
|
||||
backend, never the default (spec §2).
|
||||
26
scripts/build.sh
Executable file
26
scripts/build.sh
Executable file
@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a runnable GrammaGem.app bundle from the SwiftPM executable.
|
||||
# Usage: ./scripts/build.sh (run from the mac/ directory)
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
CONFIG="release"
|
||||
APP="GrammaGem.app"
|
||||
BUILD_DIR=".build/${CONFIG}"
|
||||
DIST="dist"
|
||||
|
||||
echo "==> Compiling (${CONFIG})"
|
||||
swift build -c "${CONFIG}"
|
||||
|
||||
echo "==> Assembling ${APP}"
|
||||
rm -rf "${DIST}/${APP}"
|
||||
mkdir -p "${DIST}/${APP}/Contents/MacOS" "${DIST}/${APP}/Contents/Resources"
|
||||
|
||||
cp "${BUILD_DIR}/GrammaGem" "${DIST}/${APP}/Contents/MacOS/GrammaGem"
|
||||
cp "AppSupport/Info.plist" "${DIST}/${APP}/Contents/Info.plist"
|
||||
# cp "AppSupport/AppIcon.icns" "${DIST}/${APP}/Contents/Resources/" # TODO: add an icon
|
||||
|
||||
echo "==> Built ${DIST}/${APP}"
|
||||
echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)"
|
||||
32
scripts/release.sh
Executable file
32
scripts/release.sh
Executable file
@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Package a signed build for distribution and update the Sparkle appcast hosted
|
||||
# on GitHub Releases (zero hosting cost). Run after sign-notarize.sh.
|
||||
#
|
||||
# This is a documented scaffold — wire it to your Sparkle EdDSA key + repo.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
APP="$ROOT/dist/GrammaGem.app"
|
||||
VERSION="${1:?Usage: release.sh <version> e.g. 0.1.0}"
|
||||
DMG="$ROOT/dist/GrammaGem-$VERSION.dmg"
|
||||
|
||||
echo "▸ Creating DMG for $VERSION…"
|
||||
# hdiutil create -volname "GrammaGem" -srcfolder "$APP" -ov -format UDZO "$DMG" # TODO: enable
|
||||
echo " (DMG creation stubbed — uncomment hdiutil line)"
|
||||
|
||||
cat <<'NOTE'
|
||||
▸ Sparkle appcast (TODO):
|
||||
1. Sign the update: ./bin/sign_update dist/GrammaGem-<ver>.dmg
|
||||
(Sparkle's sign_update tool, using your EdDSA private key)
|
||||
2. Add an <item> to appcast.xml with the version, URL (GitHub Releases asset),
|
||||
length, and sparkle:edSignature.
|
||||
3. Upload the DMG + appcast.xml to a GitHub Release.
|
||||
The app's SUFeedURL points at the raw appcast.xml on GitHub — $0 hosting.
|
||||
|
||||
▸ Distribution touchpoints (all $0 marginal cost):
|
||||
- Model weights: Hugging Face CDN
|
||||
- Updates: GitHub Releases + Sparkle
|
||||
- Licensing: Lemon Squeezy License API (% per sale only)
|
||||
NOTE
|
||||
|
||||
echo "✓ Release steps printed for $VERSION"
|
||||
33
scripts/sign-notarize.sh
Executable file
33
scripts/sign-notarize.sh
Executable file
@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Codesign (Hardened Runtime) + notarize + staple the GrammaGem.app bundle.
|
||||
# Requires the Apple Developer Program ($99/yr) — a FIXED cost, not per-user.
|
||||
#
|
||||
# Prereqs (set as env vars or fill in):
|
||||
# DEV_ID e.g. "Developer ID Application: Your Name (TEAMID)"
|
||||
# KEYCHAIN_PROFILE a stored notarytool profile (xcrun notarytool store-credentials)
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
APP="$ROOT/dist/GrammaGem.app"
|
||||
ENTITLEMENTS="$ROOT/AppSupport/GrammaGem.entitlements"
|
||||
|
||||
: "${DEV_ID:?Set DEV_ID to your Developer ID Application identity}"
|
||||
: "${KEYCHAIN_PROFILE:?Set KEYCHAIN_PROFILE to your stored notarytool profile}"
|
||||
|
||||
echo "▸ Codesigning with Hardened Runtime…"
|
||||
codesign --force --deep --options runtime \
|
||||
--entitlements "$ENTITLEMENTS" \
|
||||
--sign "$DEV_ID" \
|
||||
"$APP"
|
||||
|
||||
echo "▸ Zipping for notarization…"
|
||||
ZIP="$ROOT/dist/GrammaGem.zip"
|
||||
ditto -c -k --keepParent "$APP" "$ZIP"
|
||||
|
||||
echo "▸ Submitting to Apple notary service…"
|
||||
xcrun notarytool submit "$ZIP" --keychain-profile "$KEYCHAIN_PROFILE" --wait
|
||||
|
||||
echo "▸ Stapling ticket…"
|
||||
xcrun stapler staple "$APP"
|
||||
|
||||
echo "✓ Signed, notarized, and stapled: $APP"
|
||||
Loading…
x
Reference in New Issue
Block a user