From f5faa27f885ca6f079ef3dd46664de945d736dc4 Mon Sep 17 00:00:00 2001 From: BigMarc Date: Tue, 16 Jun 2026 13:56:26 +0200 Subject: [PATCH] =?UTF-8?q?GrammaGem=20=E2=80=94=20open-source,=20on-devic?= =?UTF-8?q?e=20macOS=20writing=20assistant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 5 + AppSupport/GrammaGem.entitlements | 21 ++ AppSupport/Info.plist | 43 ++++ LICENSE | 21 ++ Package.swift | 37 ++++ README.md | 125 +++++++++++ Resources/Modes/README.md | 14 ++ Resources/Modes/email.json | 9 + Resources/Modes/polish.json | 8 + Sources/GrammaGem/AI/AIEngine.swift | 32 +++ Sources/GrammaGem/AI/MLXEngine.swift | 91 ++++++++ Sources/GrammaGem/AI/ModeRegistry.swift | 111 ++++++++++ Sources/GrammaGem/AI/ModelManager.swift | 58 ++++++ Sources/GrammaGem/App/AppState.swift | 197 ++++++++++++++++++ Sources/GrammaGem/App/AskView.swift | 39 ++++ Sources/GrammaGem/App/GrammaGemApp.swift | 32 +++ Sources/GrammaGem/App/MenuBarContent.swift | 101 +++++++++ Sources/GrammaGem/App/OnboardingView.swift | 105 ++++++++++ Sources/GrammaGem/App/SettingsView.swift | 192 +++++++++++++++++ Sources/GrammaGem/Core/AppConfig.swift | 50 +++++ Sources/GrammaGem/Core/Logger.swift | 13 ++ Sources/GrammaGem/Core/Models.swift | 112 ++++++++++ .../GrammaGem/Entitlements/Entitlements.swift | 32 +++ .../GrammaGem/Entitlements/FeatureGate.swift | 101 +++++++++ Sources/GrammaGem/Grammar/GrammarEngine.swift | 30 +++ Sources/GrammaGem/Grammar/HarperEngine.swift | 75 +++++++ .../Grammar/PersonalDictionary.swift | 37 ++++ .../Licensing/DeviceFingerprint.swift | 42 ++++ Sources/GrammaGem/Licensing/Keychain.swift | 58 ++++++ .../Licensing/LemonSqueezyClient.swift | 125 +++++++++++ .../GrammaGem/Licensing/LicenseManager.swift | 100 +++++++++ .../GrammaGem/Licensing/LicenseModels.swift | 70 +++++++ Sources/GrammaGem/System/AppDetector.swift | 26 +++ Sources/GrammaGem/System/HotkeyManager.swift | 82 ++++++++ Sources/GrammaGem/System/Permissions.swift | 54 +++++ Sources/GrammaGem/System/TextCapture.swift | 136 ++++++++++++ .../System/TextReplacementCoordinator.swift | 109 ++++++++++ Tests/GrammaGemTests/GrammaGemTests.swift | 78 +++++++ harper-ffi/README.md | 47 +++++ scripts/build.sh | 26 +++ scripts/release.sh | 32 +++ scripts/sign-notarize.sh | 33 +++ 42 files changed, 2709 insertions(+) create mode 100644 .gitignore create mode 100644 AppSupport/GrammaGem.entitlements create mode 100644 AppSupport/Info.plist create mode 100644 LICENSE create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Resources/Modes/README.md create mode 100644 Resources/Modes/email.json create mode 100644 Resources/Modes/polish.json create mode 100644 Sources/GrammaGem/AI/AIEngine.swift create mode 100644 Sources/GrammaGem/AI/MLXEngine.swift create mode 100644 Sources/GrammaGem/AI/ModeRegistry.swift create mode 100644 Sources/GrammaGem/AI/ModelManager.swift create mode 100644 Sources/GrammaGem/App/AppState.swift create mode 100644 Sources/GrammaGem/App/AskView.swift create mode 100644 Sources/GrammaGem/App/GrammaGemApp.swift create mode 100644 Sources/GrammaGem/App/MenuBarContent.swift create mode 100644 Sources/GrammaGem/App/OnboardingView.swift create mode 100644 Sources/GrammaGem/App/SettingsView.swift create mode 100644 Sources/GrammaGem/Core/AppConfig.swift create mode 100644 Sources/GrammaGem/Core/Logger.swift create mode 100644 Sources/GrammaGem/Core/Models.swift create mode 100644 Sources/GrammaGem/Entitlements/Entitlements.swift create mode 100644 Sources/GrammaGem/Entitlements/FeatureGate.swift create mode 100644 Sources/GrammaGem/Grammar/GrammarEngine.swift create mode 100644 Sources/GrammaGem/Grammar/HarperEngine.swift create mode 100644 Sources/GrammaGem/Grammar/PersonalDictionary.swift create mode 100644 Sources/GrammaGem/Licensing/DeviceFingerprint.swift create mode 100644 Sources/GrammaGem/Licensing/Keychain.swift create mode 100644 Sources/GrammaGem/Licensing/LemonSqueezyClient.swift create mode 100644 Sources/GrammaGem/Licensing/LicenseManager.swift create mode 100644 Sources/GrammaGem/Licensing/LicenseModels.swift create mode 100644 Sources/GrammaGem/System/AppDetector.swift create mode 100644 Sources/GrammaGem/System/HotkeyManager.swift create mode 100644 Sources/GrammaGem/System/Permissions.swift create mode 100644 Sources/GrammaGem/System/TextCapture.swift create mode 100644 Sources/GrammaGem/System/TextReplacementCoordinator.swift create mode 100644 Tests/GrammaGemTests/GrammaGemTests.swift create mode 100644 harper-ffi/README.md create mode 100755 scripts/build.sh create mode 100755 scripts/release.sh create mode 100755 scripts/sign-notarize.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90cc509 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.build/ +dist/ +*.xcodeproj/xcuserdata/ +.DS_Store +.swiftpm/ diff --git a/AppSupport/GrammaGem.entitlements b/AppSupport/GrammaGem.entitlements new file mode 100644 index 0000000..ae4ce82 --- /dev/null +++ b/AppSupport/GrammaGem.entitlements @@ -0,0 +1,21 @@ + + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/AppSupport/Info.plist b/AppSupport/Info.plist new file mode 100644 index 0000000..e11b17c --- /dev/null +++ b/AppSupport/Info.plist @@ -0,0 +1,43 @@ + + + + + + CFBundleName + GrammaGem + CFBundleDisplayName + GrammaGem + CFBundleIdentifier + com.grammagem.app + CFBundleExecutable + GrammaGem + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + LSUIElement + + NSHumanReadableCopyright + © 2026 GrammaGem. Built in public. + NSPrincipalClass + NSApplication + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1f39cd7 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..f472874 --- /dev/null +++ b/Package.swift @@ -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" + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..59a9136 --- /dev/null +++ b/README.md @@ -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. diff --git a/Resources/Modes/README.md b/Resources/Modes/README.md new file mode 100644 index 0000000..3f56204 --- /dev/null +++ b/Resources/Modes/README.md @@ -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. diff --git a/Resources/Modes/email.json b/Resources/Modes/email.json new file mode 100644 index 0000000..d0fda6d --- /dev/null +++ b/Resources/Modes/email.json @@ -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." +} diff --git a/Resources/Modes/polish.json b/Resources/Modes/polish.json new file mode 100644 index 0000000..e7c7e83 --- /dev/null +++ b/Resources/Modes/polish.json @@ -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." +} diff --git a/Sources/GrammaGem/AI/AIEngine.swift b/Sources/GrammaGem/AI/AIEngine.swift new file mode 100644 index 0000000..cb03c01 --- /dev/null +++ b/Sources/GrammaGem/AI/AIEngine.swift @@ -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)" + } + } +} diff --git a/Sources/GrammaGem/AI/MLXEngine.swift b/Sources/GrammaGem/AI/MLXEngine.swift new file mode 100644 index 0000000..139bf9c --- /dev/null +++ b/Sources/GrammaGem/AI/MLXEngine.swift @@ -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) + } + } +} diff --git a/Sources/GrammaGem/AI/ModeRegistry.swift b/Sources/GrammaGem/AI/ModeRegistry.swift new file mode 100644 index 0000000..881f85f --- /dev/null +++ b/Sources/GrammaGem/AI/ModeRegistry.swift @@ -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 + } + } +} diff --git a/Sources/GrammaGem/AI/ModelManager.swift b/Sources/GrammaGem/AI/ModelManager.swift new file mode 100644 index 0000000..9cdbe5a --- /dev/null +++ b/Sources/GrammaGem/AI/ModelManager.swift @@ -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 + } +} diff --git a/Sources/GrammaGem/App/AppState.swift b/Sources/GrammaGem/App/AppState.swift new file mode 100644 index 0000000..1ddb00a --- /dev/null +++ b/Sources/GrammaGem/App/AppState.swift @@ -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) + } +} diff --git a/Sources/GrammaGem/App/AskView.swift b/Sources/GrammaGem/App/AskView.swift new file mode 100644 index 0000000..ce02754 --- /dev/null +++ b/Sources/GrammaGem/App/AskView.swift @@ -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 } + } +} diff --git a/Sources/GrammaGem/App/GrammaGemApp.swift b/Sources/GrammaGem/App/GrammaGemApp.swift new file mode 100644 index 0000000..70af3f5 --- /dev/null +++ b/Sources/GrammaGem/App/GrammaGemApp.swift @@ -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() + } +} diff --git a/Sources/GrammaGem/App/MenuBarContent.swift b/Sources/GrammaGem/App/MenuBarContent.swift new file mode 100644 index 0000000..4dabbad --- /dev/null +++ b/Sources/GrammaGem/App/MenuBarContent.swift @@ -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) + } +} diff --git a/Sources/GrammaGem/App/OnboardingView.swift b/Sources/GrammaGem/App/OnboardingView.swift new file mode 100644 index 0000000..5c819cc --- /dev/null +++ b/Sources/GrammaGem/App/OnboardingView.swift @@ -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( + 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() } } + } + } + } +} diff --git a/Sources/GrammaGem/App/SettingsView.swift b/Sources/GrammaGem/App/SettingsView.swift new file mode 100644 index 0000000..32981b9 --- /dev/null +++ b/Sources/GrammaGem/App/SettingsView.swift @@ -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 + } + } +} diff --git a/Sources/GrammaGem/Core/AppConfig.swift b/Sources/GrammaGem/Core/AppConfig.swift new file mode 100644 index 0000000..06429d2 --- /dev/null +++ b/Sources/GrammaGem/Core/AppConfig.swift @@ -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 + } +} diff --git a/Sources/GrammaGem/Core/Logger.swift b/Sources/GrammaGem/Core/Logger.swift new file mode 100644 index 0000000..fd73555 --- /dev/null +++ b/Sources/GrammaGem/Core/Logger.swift @@ -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") +} diff --git a/Sources/GrammaGem/Core/Models.swift b/Sources/GrammaGem/Core/Models.swift new file mode 100644 index 0000000..d927e32 --- /dev/null +++ b/Sources/GrammaGem/Core/Models.swift @@ -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) +} diff --git a/Sources/GrammaGem/Entitlements/Entitlements.swift b/Sources/GrammaGem/Entitlements/Entitlements.swift new file mode 100644 index 0000000..92c61b3 --- /dev/null +++ b/Sources/GrammaGem/Entitlements/Entitlements.swift @@ -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 } +} diff --git a/Sources/GrammaGem/Entitlements/FeatureGate.swift b/Sources/GrammaGem/Entitlements/FeatureGate.swift new file mode 100644 index 0000000..e4e3b89 --- /dev/null +++ b/Sources/GrammaGem/Entitlements/FeatureGate.swift @@ -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) } +} diff --git a/Sources/GrammaGem/Grammar/GrammarEngine.swift b/Sources/GrammaGem/Grammar/GrammarEngine.swift new file mode 100644 index 0000000..38282a7 --- /dev/null +++ b/Sources/GrammaGem/Grammar/GrammarEngine.swift @@ -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 { 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 + } +} diff --git a/Sources/GrammaGem/Grammar/HarperEngine.swift b/Sources/GrammaGem/Grammar/HarperEngine.swift new file mode 100644 index 0000000..a87af38 --- /dev/null +++ b/Sources/GrammaGem/Grammar/HarperEngine.swift @@ -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 = [] + + 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) + } +} diff --git a/Sources/GrammaGem/Grammar/PersonalDictionary.swift b/Sources/GrammaGem/Grammar/PersonalDictionary.swift new file mode 100644 index 0000000..97764ac --- /dev/null +++ b/Sources/GrammaGem/Grammar/PersonalDictionary.swift @@ -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) + } +} diff --git a/Sources/GrammaGem/Licensing/DeviceFingerprint.swift b/Sources/GrammaGem/Licensing/DeviceFingerprint.swift new file mode 100644 index 0000000..67f7c2d --- /dev/null +++ b/Sources/GrammaGem/Licensing/DeviceFingerprint.swift @@ -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 + } +} diff --git a/Sources/GrammaGem/Licensing/Keychain.swift b/Sources/GrammaGem/Licensing/Keychain.swift new file mode 100644 index 0000000..eb5ce30 --- /dev/null +++ b/Sources/GrammaGem/Licensing/Keychain.swift @@ -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(_ value: T, account: String) { + if let data = try? JSONEncoder().encode(value) { set(data, account: account) } + } + + static func getObject(_ type: T.Type, account: String) -> T? { + guard let data = get(account: account) else { return nil } + return try? JSONDecoder().decode(type, from: data) + } +} diff --git a/Sources/GrammaGem/Licensing/LemonSqueezyClient.swift b/Sources/GrammaGem/Licensing/LemonSqueezyClient.swift new file mode 100644 index 0000000..6ae024e --- /dev/null +++ b/Sources/GrammaGem/Licensing/LemonSqueezyClient.swift @@ -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(_ 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: "&") + } +} diff --git a/Sources/GrammaGem/Licensing/LicenseManager.swift b/Sources/GrammaGem/Licensing/LicenseManager.swift new file mode 100644 index 0000000..cbaec6c --- /dev/null +++ b/Sources/GrammaGem/Licensing/LicenseManager.swift @@ -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 + } +} diff --git a/Sources/GrammaGem/Licensing/LicenseModels.swift b/Sources/GrammaGem/Licensing/LicenseModels.swift new file mode 100644 index 0000000..95146aa --- /dev/null +++ b/Sources/GrammaGem/Licensing/LicenseModels.swift @@ -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 + } + } +} diff --git a/Sources/GrammaGem/System/AppDetector.swift b/Sources/GrammaGem/System/AppDetector.swift new file mode 100644 index 0000000..f0bfddf --- /dev/null +++ b/Sources/GrammaGem/System/AppDetector.swift @@ -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) + } +} diff --git a/Sources/GrammaGem/System/HotkeyManager.swift b/Sources/GrammaGem/System/HotkeyManager.swift new file mode 100644 index 0000000..29d7ccb --- /dev/null +++ b/Sources/GrammaGem/System/HotkeyManager.swift @@ -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.size, nil, &hkID) + guard err == noErr else { return err } + let mgr = Unmanaged.fromOpaque(userData).takeUnretainedValue() + if let action = mgr.idToAction[hkID.id] { + DispatchQueue.main.async { mgr.onAction?(action) } + } + return noErr + }, + 1, &spec, selfPtr, &handler) + } +} diff --git a/Sources/GrammaGem/System/Permissions.swift b/Sources/GrammaGem/System/Permissions.swift new file mode 100644 index 0000000..ee349cf --- /dev/null +++ b/Sources/GrammaGem/System/Permissions.swift @@ -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() } + } + } + } +} diff --git a/Sources/GrammaGem/System/TextCapture.swift b/Sources/GrammaGem/System/TextCapture.swift new file mode 100644 index 0000000..71821f4 --- /dev/null +++ b/Sources/GrammaGem/System/TextCapture.swift @@ -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 ⌘ 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) + } +} diff --git a/Sources/GrammaGem/System/TextReplacementCoordinator.swift b/Sources/GrammaGem/System/TextReplacementCoordinator.swift new file mode 100644 index 0000000..e1f17b6 --- /dev/null +++ b/Sources/GrammaGem/System/TextReplacementCoordinator.swift @@ -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 + } +} diff --git a/Tests/GrammaGemTests/GrammaGemTests.swift b/Tests/GrammaGemTests/GrammaGemTests.swift new file mode 100644 index 0000000..17f4c96 --- /dev/null +++ b/Tests/GrammaGemTests/GrammaGemTests.swift @@ -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) + } +} diff --git a/harper-ffi/README.md b/harper-ffi/README.md new file mode 100644 index 0000000..2d4153f --- /dev/null +++ b/harper-ffi/README.md @@ -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). diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..6367ce7 --- /dev/null +++ b/scripts/build.sh @@ -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)" diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..db7a335 --- /dev/null +++ b/scripts/release.sh @@ -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 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-.dmg + (Sparkle's sign_update tool, using your EdDSA private key) + 2. Add an 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" diff --git a/scripts/sign-notarize.sh b/scripts/sign-notarize.sh new file mode 100755 index 0000000..58421d7 --- /dev/null +++ b/scripts/sign-notarize.sh @@ -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"