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