diff --git a/.gitignore b/.gitignore index 90cc509..b53644e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,11 @@ dist/ *.xcodeproj/xcuserdata/ .DS_Store .swiftpm/ + +# Rust / Harper FFI build artifacts (regenerate with harper-ffi/build.sh). +# Cargo.lock IS committed (binary crate) for reproducible builds. +harper-ffi/target/ +harper-ffi/lib/ + +# xcodebuild DerivedData (used only to generate the MLX metallib) +.xcode-build/ diff --git a/AppSupport/GrammaGem.entitlements b/AppSupport/GrammarGem.entitlements similarity index 100% rename from AppSupport/GrammaGem.entitlements rename to AppSupport/GrammarGem.entitlements diff --git a/AppSupport/Info.plist b/AppSupport/Info.plist index 8b4ef3c..31c84ac 100644 --- a/AppSupport/Info.plist +++ b/AppSupport/Info.plist @@ -1,6 +1,6 @@ + SUFeedURL + https://www.grammargem.com/appcast.xml + SUPublicEDKey + IwYch+DRgNk3gmCwe86z+SIc4sRdyDf4Z6xGpS+JXDc= + SUEnableAutomaticChecks + + SUScheduledCheckInterval + 86400 + SUAutomaticallyUpdate + diff --git a/AppSupport/mlx.metallib b/AppSupport/mlx.metallib new file mode 100644 index 0000000..50598b9 Binary files /dev/null and b/AppSupport/mlx.metallib differ diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..2e0f4f3 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,78 @@ +{ + "originHash" : "f78005d8f2f9a6a31d5c42cc7ce2b46907eb92fce74810060a58056e592ea3a1", + "pins" : [ + { + "identity" : "gzipswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/1024jp/GzipSwift", + "state" : { + "revision" : "731037f6cc2be2ec01562f6597c1d0aa3fe6fd05", + "version" : "6.0.1" + } + }, + { + "identity" : "mlx-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ml-explore/mlx-swift", + "state" : { + "revision" : "96c0f69cf15f38abd6248bb71fca908171dab1d8", + "version" : "0.25.6" + } + }, + { + "identity" : "mlx-swift-examples", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ml-explore/mlx-swift-examples.git", + "state" : { + "revision" : "8775112da257e8b0a5bf45e3f84fb80222592c89", + "version" : "2.25.9" + } + }, + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "d46d456107feacc80711b21847b82b07bd9fb46e", + "version" : "2.9.3" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-jinja", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-jinja.git", + "state" : { + "revision" : "0b67ecb79139f6addef8699eff3622808aa6c7dc", + "version" : "2.3.6" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-transformers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-transformers", + "state" : { + "revision" : "a2e184dddb4757bc943e77fbe99ac6786c53f0b2", + "version" : "1.0.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index f472874..bceb8a5 100644 --- a/Package.swift +++ b/Package.swift @@ -1,37 +1,58 @@ // swift-tools-version:5.10 import PackageDescription +import Foundation -// GrammaGem — native macOS menu-bar writing assistant. +// GrammarGem — native macOS menu-bar writing assistant. // -// This package builds with ONLY Apple frameworks so it compiles cleanly out of -// the box (`swift build`). The heavyweight third-party pieces from the spec — -// Harper (Rust grammar core), MLX (local LLM), KeyboardShortcuts, Sparkle — are -// abstracted behind protocols with working stub implementations and clearly -// marked `TODO(real-integration)` seams. See README.md for wiring the real deps. +// Layer-1 grammar is the real **Harper** core (Apache-2.0), embedded as a Rust +// C-FFI static library (see `harper-ffi/`). Build the lib first with +// `harper-ffi/build.sh`; `scripts/build.sh` does this automatically. +// +// Layer-2 AI (rewrite / tone / Ask) is the real on-device LLM via MLX +// (mlx-swift-examples). MLX is **Apple-Silicon / Metal only**, so the app is +// arm64-only (matches the product spec: Apple Silicon, macOS 14+). The Harper +// static lib stays universal; the linker just uses its arm64 slice. + +// Absolute path to the prebuilt Harper static lib, independent of build cwd. +let packageRoot = URL(fileURLWithPath: #filePath).deletingLastPathComponent().path +let harperLibDir = packageRoot + "/harper-ffi/lib" + let package = Package( - name: "GrammaGem", + name: "GrammarGem", platforms: [ .macOS(.v14) // Apple Silicon, macOS 14+ (per the product spec) ], products: [ - .executable(name: "GrammaGem", targets: ["GrammaGem"]) + .executable(name: "GrammarGem", targets: ["GrammarGem"]) ], dependencies: [ - // TODO(real-integration): add when wiring real engines / distribution. - // .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.0.0"), - // .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"), - // .package(url: "https://github.com/ml-explore/mlx-swift", from: "0.18.0"), - // The Harper grammar core is bundled via a Rust static lib + C-FFI; see harper-ffi/. + // On-device LLM runtime. Pinned to an exact tag (the LLM libraries still + // ship from this repo at 2.25.9; the package name is "mlx-libraries"). + .package(url: "https://github.com/ml-explore/mlx-swift-examples.git", exact: "2.25.9"), + // Secure auto-updates (EdDSA-signed appcast served from grammargem.com). + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"), ], targets: [ + // C shim exposing libharper_ffi's C ABI (harper-ffi/include/harper.h) to Swift. + .target(name: "CHarper", path: "Sources/CHarper"), .executableTarget( - name: "GrammaGem", - path: "Sources/GrammaGem" + name: "GrammarGem", + dependencies: [ + "CHarper", + .product(name: "MLXLLM", package: "mlx-swift-examples"), + .product(name: "MLXLMCommon", package: "mlx-swift-examples"), + .product(name: "Sparkle", package: "Sparkle"), + ], + path: "Sources/GrammarGem", + linkerSettings: [ + // Link the prebuilt Harper static library (arm64 slice). + .unsafeFlags(["-L\(harperLibDir)", "-lharper_ffi"]) + ] ), .testTarget( - name: "GrammaGemTests", - dependencies: ["GrammaGem"], - path: "Tests/GrammaGemTests" + name: "GrammarGemTests", + dependencies: ["GrammarGem"], + path: "Tests/GrammarGemTests" ), ] ) diff --git a/README.md b/README.md index c4b5b01..5a7a26e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-# ✒️ GrammaGem +# ✒️ GrammarGem ### Write it once. Get it right. @@ -12,7 +12,7 @@ Fix grammar, sharpen tone, and rewrite anything — in every app — for one pay ![One-time purchase](https://img.shields.io/badge/pricing-one--time-C9A24B) ![License: MIT](https://img.shields.io/badge/license-MIT-0E7C5A) -[**⬇️ Download GrammaGem**](https://grammagem.app/#pricing) · [Docs](https://grammagem.app/docs) · [How it works](https://grammagem.app) +[**⬇️ Download GrammarGem**](https://www.grammargem.com/#pricing) · [Docs](https://www.grammargem.com/docs) · [How it works](https://www.grammargem.com)
@@ -20,7 +20,7 @@ Fix grammar, sharpen tone, and rewrite anything — in every app — for one pay ## Stop renting your grammar checker. -Grammarly costs around **$144 every year**, reads everything you type, and sends it to their servers. GrammaGem does the same job — **better** — entirely on your Mac, for a **single payment**. +Grammarly costs around **$144 every year**, reads everything you type, and sends it to their servers. GrammarGem does the same job — **better** — entirely on your Mac, for a **single payment**. Select text in any app, press one hotkey, and it’s polished in place. No copy-paste, no switching windows, and **nothing ever leaves your machine**. @@ -30,7 +30,7 @@ Select text in any app, press one hotkey, and it’s polished in place. No copy- ## Why people switch -| | Grammarly | **GrammaGem** | +| | Grammarly | **GrammarGem** | |---|:---:|:---:| | **Price** | ~$144 / year, forever | **Pay once** | | **Your words** | Sent to the cloud | **Never leave your Mac** | @@ -46,7 +46,7 @@ Select text in any app, press one hotkey, and it’s polished in place. No copy- - 🔒 **Private by design** — everything runs on your Mac’s Neural Engine. Your writing is never uploaded, logged, or used to train a model. - ⌨️ **One global hotkey** — fix or rewrite the selected text in any app: Mail, Slack, Notion, Chrome, Messages, VS Code, and more. - ✨ **Ask & rewrite** — “make it shorter,” “more formal,” “translate to German” — done in place. -- 🎚️ **Writing Modes** — Email, Post, Team Chat, Academic, Code — switch the tone instantly, or let GrammaGem pick the right one for the app you’re in. +- 🎚️ **Writing Modes** — Email, Post, Team Chat, Academic, Code — switch the tone instantly, or let GrammarGem pick the right one for the app you’re in. - 📖 **Personal dictionary** — teach it your names, brand terms, and jargon once; it stops “correcting” them forever. - 🖥️ **A clean management app** — manage your devices, shortcuts, modes, and privacy from one tidy window. @@ -67,7 +67,7 @@ No subscription. No per-seat fees. One payment, lifetime updates.
-[**⬇️ Get GrammaGem**](https://grammagem.app/#pricing) +[**⬇️ Get GrammarGem**](https://www.grammargem.com/#pricing)
@@ -85,17 +85,17 @@ No. Core features work fully offline. No — it’s a one-time purchase with lifetime updates and a 14-day money-back guarantee. **What about plagiarism detection?** -GrammaGem is private-first, so it doesn’t do cloud plagiarism scanning — that would require sending your writing to a server. Everything else you’d expect is here. +GrammarGem is private-first, so it doesn’t do cloud plagiarism scanning — that would require sending your writing to a server. Everything else you’d expect is here. --- ## Built in public -GrammaGem is open source under the **MIT License**. We believe a privacy tool should be one you can actually inspect. Issues and pull requests are welcome. +GrammarGem is open source under the **MIT License**. We believe a privacy tool should be one you can actually inspect. Issues and pull requests are welcome.
-**[grammagem.app](https://grammagem.app)** +**[grammargem.com](https://www.grammargem.com)** © 2026 FounderGem LLC · 1309 Coffeen Avenue STE 1200, Sheridan, WY 82801, USA diff --git a/Resources/Modes/README.md b/Resources/Modes/README.md index 3f56204..f343466 100644 --- a/Resources/Modes/README.md +++ b/Resources/Modes/README.md @@ -1,7 +1,7 @@ # Built-in Writing Modes Each Mode is a named system-prompt preset for the local LLM. The canonical, -compiled definitions live in [`Sources/GrammaGem/AI/ModeRegistry.swift`](../../Sources/GrammaGem/AI/ModeRegistry.swift); +compiled definitions live in [`Sources/GrammarGem/AI/ModeRegistry.swift`](../../Sources/GrammarGem/AI/ModeRegistry.swift); these JSON files mirror them for transparency and easy editing. | Mode | Tier | In public repo? | diff --git a/Sources/CHarper/include/harper.h b/Sources/CHarper/include/harper.h new file mode 100644 index 0000000..45d5111 --- /dev/null +++ b/Sources/CHarper/include/harper.h @@ -0,0 +1,37 @@ +/* + * harper.h — C ABI for the harper-ffi static library (harper-core 2.5.0). + * + * Symbols are provided by libharper_ffi.a (linked via -lharper_ffi). All + * strings are UTF-8. See harper-ffi/ for the Rust source. + */ +#ifndef HARPER_FFI_H +#define HARPER_FFI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Check `text` for grammar/spelling issues using harper-core. + * + * Returns a malloc'd, NUL-terminated UTF-8 JSON C string the caller MUST free + * with harper_free(). The JSON is an array of objects: + * [{ "start": , "len": , + * "replacement": "", "message": "", "kind": "" }] + * + * - "start"/"len" are CHAR (Unicode scalar) offsets, NOT bytes/UTF-16. + * - "kind" is one of: spelling, grammar, punctuation, repetition, + * capitalization, phrasing. + * + * On null/invalid input or any internal error, returns "[]". Thread-safe. + */ +char *harper_check(const char *text); + +/* Free a string previously returned by harper_check(). NULL is a no-op. */ +void harper_free(char *ptr); + +#ifdef __cplusplus +} +#endif + +#endif /* HARPER_FFI_H */ diff --git a/Sources/CHarper/include/module.modulemap b/Sources/CHarper/include/module.modulemap new file mode 100644 index 0000000..67118c8 --- /dev/null +++ b/Sources/CHarper/include/module.modulemap @@ -0,0 +1,4 @@ +module CHarper { + header "harper.h" + export * +} diff --git a/Sources/CHarper/shim.c b/Sources/CHarper/shim.c new file mode 100644 index 0000000..4b76007 --- /dev/null +++ b/Sources/CHarper/shim.c @@ -0,0 +1,4 @@ +// CHarper is a header-only shim that exposes libharper_ffi's C ABI (declared in +// include/harper.h) to Swift as the `CHarper` module. The actual symbols are +// provided by libharper_ffi.a, linked via the GrammarGem target's linkerSettings. +// This translation unit exists only so SwiftPM treats CHarper as a C target. diff --git a/Sources/GrammaGem/AI/AIEngine.swift b/Sources/GrammaGem/AI/AIEngine.swift deleted file mode 100644 index cb03c01..0000000 --- a/Sources/GrammaGem/AI/AIEngine.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation - -/// Layer 2 — local-LLM-powered rewriting / tone / "Ask" / translate. -/// -/// Backed in production by **MLX** (mlx-swift) running a small instruct model -/// downloaded once from Hugging Face. The protocol isolates the rest of the app -/// from the runtime so the UI, gating, and tests don't depend on MLX being present. -protocol AIEngine: AnyObject { - /// Whether a model is loaded and ready to run locally. - var isReady: Bool { get } - - /// Run an action against `text` and return the rewritten result. - /// Throws on model-not-ready or generation failure. Never performs network I/O. - func run(_ action: AIAction, on text: String) async throws -> String - - /// Best-effort local tone classification (used by tone-detection UI). - func detectTone(_ text: String) async -> Tone -} - -enum AIEngineError: LocalizedError { - case modelNotReady - case generationFailed(String) - - var errorDescription: String? { - switch self { - case .modelNotReady: - return "The on-device model isn't ready yet. Finish the first-run download in Settings." - case .generationFailed(let why): - return "On-device rewrite failed: \(why)" - } - } -} diff --git a/Sources/GrammaGem/AI/MLXEngine.swift b/Sources/GrammaGem/AI/MLXEngine.swift deleted file mode 100644 index 139bf9c..0000000 --- a/Sources/GrammaGem/AI/MLXEngine.swift +++ /dev/null @@ -1,91 +0,0 @@ -import Foundation - -/// STUB standing in for the real MLX-backed local LLM. -/// -/// TODO(real-integration): load the model via mlx-swift from -/// `ModelManager.modelsDirectory`, build the prompt from the action's system -/// prompt + the user's text, and stream tokens (target: first token < 1s on -/// M-series). Everything stays on-device — this method must never hit the network. -/// -/// The stub produces deterministic, plausible transformations so the capture → -/// engine → replace loop and the entitlement gating can be exercised end-to-end -/// without the multi-gigabyte model present. -final class MLXEngine: AIEngine { - private let ready: Bool - - init(ready: Bool = true) { - self.ready = ready - } - - var isReady: Bool { ready } - - func run(_ action: AIAction, on text: String) async throws -> String { - guard ready else { throw AIEngineError.modelNotReady } - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return text } - - switch action { - case .rewriteClarity, .rewrite: - return tidy(trimmed) - case .adjustTone(let tone): - return apply(tone: tone, to: tidy(trimmed)) - case .ask(let instruction): - return follow(instruction: instruction, on: trimmed) - case .translate(let language): - return "[\(language)] " + tidy(trimmed) - case .applyMode(let mode): - return applyMode(mode, to: trimmed) - } - } - - func detectTone(_ text: String) async -> Tone { - let lower = text.lowercased() - if lower.contains("!") || lower.contains("🚀") { return .punchy } - if lower.contains("therefore") || lower.contains("hereby") { return .academic } - if lower.contains("hey") || lower.contains("lol") { return .friendly } - return .professional - } - - // MARK: - Deterministic stand-in transforms - - private func tidy(_ s: String) -> String { - var out = s - out = out.replacingOccurrences(of: " i ", with: " I ") - out = out.replacingOccurrences(of: "lmk", with: "let me know") - out = out.replacingOccurrences(of: "u ", with: "you ") - if let first = out.first, first.isLowercase { - out.replaceSubrange(out.startIndex...out.startIndex, with: String(first).uppercased()) - } - if let last = out.last, !".!?".contains(last) { out.append(".") } - return out - } - - private func apply(tone: Tone, to s: String) -> String { - switch tone { - case .professional: return s - case .friendly: return "Hi! " + s - case .confident: return s.replacingOccurrences(of: "I think ", with: "") - case .academic: return "It is worth noting that " + s.prefix(1).lowercased() + s.dropFirst() - case .punchy: return s.replacingOccurrences(of: ". ", with: ".\n") - } - } - - private func follow(instruction: String, on s: String) -> String { - let i = instruction.lowercased() - if i.contains("short") { return String(s.split(separator: " ").prefix(8).joined(separator: " ")) + "." } - if i.contains("formal") { return apply(tone: .academic, to: tidy(s)) } - if i.contains("translate") { return "[translated] " + tidy(s) } - return tidy(s) - } - - private func applyMode(_ mode: WritingMode, to s: String) -> String { - switch mode.id { - case "email": return "Hi,\n\n\(tidy(s))\n\nThanks!" - case "post": return apply(tone: .punchy, to: tidy(s)) - case "academic": return apply(tone: .academic, to: tidy(s)) - case "slack": return s.lowercased() - case "code": return "// " + tidy(s) - default: return tidy(s) - } - } -} diff --git a/Sources/GrammaGem/App/GrammaGemApp.swift b/Sources/GrammaGem/App/GrammaGemApp.swift deleted file mode 100644 index b2d538f..0000000 --- a/Sources/GrammaGem/App/GrammaGemApp.swift +++ /dev/null @@ -1,34 +0,0 @@ -import SwiftUI -import AppKit - -/// App entry point. A menu-bar (`LSUIElement`) SwiftUI app: the only persistent -/// scene is the `MenuBarExtra`; Settings uses the standard scene; Onboarding and -/// Ask are AppKit-hosted windows opened on demand (see `AppState`). -@main -struct GrammaGemApp: App { - @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - @StateObject private var app = AppState.shared - - var body: some Scene { - MenuBarExtra { - MenuBarContent() - .environmentObject(app) - } label: { - MenuBarLabel() - } - .menuBarExtraStyle(.window) - - Settings { - SettingsView() - .environmentObject(app) - .frame(width: 560, height: 520) - } - } -} - -/// Runs launch-time wiring (hotkeys, permissions, license validation). -final class AppDelegate: NSObject, NSApplicationDelegate { - func applicationDidFinishLaunching(_ notification: Notification) { - AppState.shared.start() - } -} diff --git a/Sources/GrammaGem/App/OnboardingView.swift b/Sources/GrammaGem/App/OnboardingView.swift deleted file mode 100644 index 22d7b0c..0000000 --- a/Sources/GrammaGem/App/OnboardingView.swift +++ /dev/null @@ -1,138 +0,0 @@ -import SwiftUI - -/// First-run onboarding: explains why GrammaGem needs Accessibility, deep-links -/// to System Settings, and advances automatically the moment permission is -/// granted (even if granted directly in System Settings). -struct OnboardingView: View { - @EnvironmentObject private var app: AppState - @ObservedObject private var permissions: Permissions - @ObservedObject private var model: ModelManager - - init() { - _permissions = ObservedObject(wrappedValue: AppState.shared.permissions) - _model = ObservedObject(wrappedValue: AppState.shared.model) - } - - var body: some View { - VStack(alignment: .leading, spacing: 18) { - VStack(alignment: .leading, spacing: 6) { - Text("Welcome to GrammaGem") - .font(.largeTitle.bold()) - Text("A private, on-device writing assistant that works in every app.") - .foregroundStyle(.secondary) - } - - stepCard( - number: 1, - title: "Grant Accessibility access", - body: "GrammaGem reads the text you select and writes the correction back. macOS requires Accessibility permission for this. Your text is processed on-device and never leaves your Mac.", - granted: permissions.accessibilityTrusted - ) { - if permissions.accessibilityTrusted { - Label("Permission granted", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - } else { - HStack { - Button("Open System Settings") { permissions.openAccessibilitySettings() } - Button("Request permission") { permissions.requestAccessibility() } - } - Text("This updates automatically once you flip GrammaGem on in Accessibility.") - .font(.caption).foregroundStyle(.secondary) - } - } - - stepCard( - number: 2, - title: "Download the on-device model (optional)", - body: "Grammar & spelling work instantly with no download. The AI rewrite/tone/Ask features use a small model downloaded once from Hugging Face — then everything runs offline.", - granted: model.state == .ready - ) { - ModelDownloadControls() - } - - Spacer() - - HStack { - if permissions.accessibilityTrusted { - Label("You're all set", systemImage: "sparkles").foregroundStyle(.secondary) - } - Spacer() - Button(permissions.accessibilityTrusted ? "Done" : "Continue without permission") { - app.showOnboardingDismiss() - } - .keyboardShortcut(.defaultAction) - } - } - .padding(28) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .onAppear { - permissions.startPollingUntilGranted() - } - .onDisappear { - permissions.stopPolling() - } - } - - private func stepCard( - number: Int, title: String, body: String, granted: Bool, - @ViewBuilder controls: () -> Controls - ) -> some View { - HStack(alignment: .top, spacing: 14) { - ZStack { - Circle().fill(granted ? Color.green : Color.accentColor.opacity(0.15)) - .frame(width: 30, height: 30) - if granted { - Image(systemName: "checkmark").foregroundStyle(.white).font(.caption.bold()) - } else { - Text("\(number)").font(.callout.bold()).foregroundStyle(.tint) - } - } - VStack(alignment: .leading, spacing: 8) { - Text(title).font(.headline) - Text(body).font(.callout).foregroundStyle(.secondary) - controls() - } - } - .padding(16) - .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 12)) - } -} - -/// Real model download progress + controls, shared by onboarding and settings. -struct ModelDownloadControls: View { - @ObservedObject private var model: ModelManager - - init() { - _model = ObservedObject(wrappedValue: AppState.shared.model) - } - - var body: some View { - switch model.state { - case .notDownloaded: - VStack(alignment: .leading, spacing: 4) { - Button("Download model now") { model.startDownload() } - Text("Installs automatically in the background on first launch (about 1–2 GB from Hugging Face). One time, then fully offline.") - .font(.caption).foregroundStyle(.secondary) - } - case .downloading(let progress): - VStack(alignment: .leading, spacing: 6) { - ProgressView(value: progress) { - Text("Downloading… \(Int(progress * 100))%") - } - .frame(maxWidth: 320) - if !model.statusText.isEmpty { - Text(model.statusText).font(.caption).foregroundStyle(.secondary).lineLimit(1) - } - Button("Cancel") { model.cancel() }.controlSize(.small) - } - case .ready: - Label("Model ready", systemImage: "checkmark.seal.fill").foregroundStyle(.green) - case .failed(let message): - VStack(alignment: .leading, spacing: 4) { - Label("Download failed", systemImage: "exclamationmark.triangle").foregroundStyle(.orange) - Text(message).font(.caption).foregroundStyle(.secondary).lineLimit(2) - Button("Retry") { model.startDownload() } - } - } - } -} diff --git a/Sources/GrammaGem/Grammar/HarperEngine.swift b/Sources/GrammaGem/Grammar/HarperEngine.swift deleted file mode 100644 index a87af38..0000000 --- a/Sources/GrammaGem/Grammar/HarperEngine.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Foundation - -/// STUB standing in for the real Harper core. -/// -/// TODO(real-integration): replace the rule set below with calls into the Harper -/// Rust crate over C-FFI (`harper_check(text) -> spans`). This stub implements a -/// handful of Harper-style deterministic rules so the system loop, live -/// underlines, and tests have something real to exercise. It runs in well under -/// 10ms and is fully offline — matching Harper's performance profile. -final class HarperEngine: GrammarEngine { - var ignoreList: Set = [] - - 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/SystemGrammarEngine.swift b/Sources/GrammaGem/Grammar/SystemGrammarEngine.swift deleted file mode 100644 index 2854b78..0000000 --- a/Sources/GrammaGem/Grammar/SystemGrammarEngine.swift +++ /dev/null @@ -1,93 +0,0 @@ -import Foundation -import AppKit - -/// Real, on-device grammar + spelling using macOS's built-in `NSSpellChecker` -/// (the same engine TextEdit/Mail use). No network, no model — fully local, -/// matching the privacy promise. Augmented with a few deterministic Harper-style -/// rules (repeated words, lowercase "i", common expansions) the system checker -/// doesn't surface. -final class SystemGrammarEngine: GrammarEngine { - var ignoreList: Set = [] - - private let checker = NSSpellChecker.shared - private let tag = NSSpellChecker.uniqueSpellDocumentTag() - private let extras = HarperEngine() - - func check(_ text: String) -> [Suggestion] { - guard text.count >= 2 else { return [] } - return onMain { self.checkOnMain(text) } - } - - private func checkOnMain(_ text: String) -> [Suggestion] { - let ns = text as NSString - let full = NSRange(location: 0, length: ns.length) - let language = checker.language() - var out: [Suggestion] = [] - - let types = NSTextCheckingResult.CheckingType([.spelling, .grammar]).rawValue - let results = checker.check( - text, range: full, types: types, options: nil, - inSpellDocumentWithTag: tag, orthography: nil, wordCount: nil) - - for r in results { - switch r.resultType { - case .spelling: - let word = ns.substring(with: r.range) - if ignoreList.contains(word.lowercased()) { continue } - let fix = checker.correction( - forWordRange: r.range, in: text, language: language, inSpellDocumentWithTag: tag) - ?? checker.guesses( - forWordRange: r.range, in: text, language: language, - inSpellDocumentWithTag: tag)?.first - out.append(Suggestion( - location: r.range.location, length: r.range.length, - original: word, replacement: fix ?? word, - kind: .spelling, message: "Possible spelling mistake")) - - case .grammar: - let details = r.grammarDetails ?? [] - if details.isEmpty { - let phrase = ns.substring(with: r.range) - out.append(Suggestion( - location: r.range.location, length: r.range.length, - original: phrase, replacement: phrase, - kind: .grammar, message: "Grammar issue")) - } else { - for d in details { - var loc = r.range.location, len = r.range.length - if let rv = d[NSGrammarRange] as? NSValue { - let sub = rv.rangeValue - loc = r.range.location + sub.location - len = sub.length - } - guard len > 0, loc + len <= ns.length else { continue } - let phrase = ns.substring(with: NSRange(location: loc, length: len)) - let message = (d[NSGrammarUserDescription] as? String) ?? "Grammar issue" - let corrections = (d[NSGrammarCorrections] as? [String]) ?? [] - out.append(Suggestion( - location: loc, length: len, - original: phrase, replacement: corrections.first ?? phrase, - kind: .grammar, message: message)) - } - } - default: - break - } - } - - // Deterministic extras, skipping anything overlapping a system result. - extras.ignoreList = ignoreList - for h in extras.check(text) where !out.contains(where: { overlaps($0.range, h.range) }) { - out.append(h) - } - return out.sorted { $0.location < $1.location } - } - - private func overlaps(_ a: NSRange, _ b: NSRange) -> Bool { - NSIntersectionRange(a, b).length > 0 - } - - private func onMain(_ work: @escaping () -> T) -> T { - Thread.isMainThread ? work() : DispatchQueue.main.sync(execute: work) - } -} diff --git a/Sources/GrammaGem/Stats/UsageStats.swift b/Sources/GrammaGem/Stats/UsageStats.swift deleted file mode 100644 index 3495f35..0000000 --- a/Sources/GrammaGem/Stats/UsageStats.swift +++ /dev/null @@ -1,90 +0,0 @@ -import Foundation - -/// Privacy-friendly, fully-local usage insights. Counts *how often* features are -/// used — never *what* was written. Stored only on this Mac. -@MainActor -final class UsageStats: ObservableObject { - struct Snapshot: Codable, Equatable { - var totalCorrections = 0 - var totalAIActions = 0 - var totalWords = 0 - var perDay: [String: Int] = [:] // "yyyy-MM-dd" -> actions that day - } - - @Published private(set) var data: Snapshot - - private let key = "GrammaGem.usageStats" - private let d = UserDefaults.standard - - private static let fmt: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd" - f.locale = Locale(identifier: "en_US_POSIX") - f.timeZone = .current - return f - }() - - init() { - if let raw = d.data(forKey: key), - let decoded = try? JSONDecoder().decode(Snapshot.self, from: raw) { - data = decoded - } else { - data = Snapshot() - } - } - - func recordCorrection(words: Int) { - data.totalCorrections += 1 - data.totalWords += max(0, words) - bumpToday() - persist() - } - - func recordAIAction(words: Int) { - data.totalAIActions += 1 - data.totalWords += max(0, words) - bumpToday() - persist() - } - - /// A playful, clearly-estimated "time saved" figure (local only). - var estimatedMinutesSaved: Int { - (data.totalCorrections * 8 + data.totalAIActions * 45) / 60 - } - - /// Counts for the last 7 calendar days, oldest → newest, for a small chart. - func last7Days() -> [(label: String, count: Int)] { - let cal = Calendar.current - let today = Date() - return (0..<7).reversed().map { offset in - let day = cal.date(byAdding: .day, value: -offset, to: today) ?? today - let key = Self.fmt.string(from: day) - let short = day.formatted(.dateTime.weekday(.narrow)) - return (label: short, count: data.perDay[key] ?? 0) - } - } - - func reset() { - data = Snapshot() - persist() - } - - private func bumpToday() { - let k = Self.fmt.string(from: Date()) - data.perDay[k, default: 0] += 1 - // Keep only ~60 days of history. - if data.perDay.count > 70 { - let keep = Set((0..<60).compactMap { off -> String? in - guard let day = Calendar.current.date(byAdding: .day, value: -off, to: Date()) else { return nil } - return Self.fmt.string(from: day) - }) - data.perDay = data.perDay.filter { keep.contains($0.key) } - } - } - - private func persist() { - if let raw = try? JSONEncoder().encode(data) { - d.set(raw, forKey: key) - } - } -} diff --git a/Sources/GrammaGem/System/AXSupport.swift b/Sources/GrammaGem/System/AXSupport.swift deleted file mode 100644 index 7dd5567..0000000 --- a/Sources/GrammaGem/System/AXSupport.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation -import ApplicationServices - -/// Safe Accessibility helpers shared across the System layer. -/// -/// `AXUIElementCopyAttributeValue` returning `.success` only guarantees a -/// non-null `CFTypeRef` — NOT that it is an `AXUIElement`. A misbehaving AX -/// server (Electron/Java/custom) can return a different CF type, and a forced -/// `as!` cast then traps and crashes the whole app. These helpers cast safely. -enum AX { - /// Safely bridge a CFTypeRef to AXUIElement, returning nil on type mismatch. - static func element(_ ref: CFTypeRef?) -> AXUIElement? { - guard let ref, CFGetTypeID(ref) == AXUIElementGetTypeID() else { return nil } - return (ref as! AXUIElement) // safe: type id verified above - } - - /// Copy an attribute as an `AXUIElement` (nil on failure/type mismatch). - static func copyElement(_ element: AXUIElement, _ attribute: String) -> AXUIElement? { - var ref: CFTypeRef? - guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success else { - return nil - } - return AX.element(ref) - } - - /// Copy an attribute as a `String` (nil on failure/wrong type). - static func copyString(_ element: AXUIElement, _ attribute: String) -> String? { - var ref: CFTypeRef? - guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success else { - return nil - } - return ref as? String - } - - /// Bound each cross-process AX message so a wedged target app can't hang us. - static func setMessagingTimeout(_ element: AXUIElement, _ seconds: Float) { - AXUIElementSetMessagingTimeout(element, seconds) - } -} diff --git a/Sources/GrammarGem/AI/AIEngine.swift b/Sources/GrammarGem/AI/AIEngine.swift new file mode 100644 index 0000000..1911059 --- /dev/null +++ b/Sources/GrammarGem/AI/AIEngine.swift @@ -0,0 +1,73 @@ +import Foundation + +/// Layer 2 — local-LLM-powered rewriting / tone / "Ask" / translate. +/// +/// Backed in production by **MLX** (mlx-swift) running a small instruct model +/// downloaded once from Hugging Face. The protocol isolates the rest of the app +/// from the runtime so the UI, gating, and tests don't depend on MLX being present. +protocol AIEngine: AnyObject { + /// Whether a model is loaded and ready to run locally. + var isReady: Bool { get } + + /// Run an action against `text` and return the rewritten result. + /// `protectedTerms` are vocabulary the model must reproduce verbatim (the + /// personal dictionary / style-guide terms). Throws on model-not-ready or + /// generation failure. Never performs network I/O on the on-device backend. + func run(_ action: AIAction, on text: String, protectedTerms: [String]) async throws -> String + + /// Best-effort local tone classification (used by tone-detection UI). + func detectTone(_ text: String) async -> Tone + + /// Optionally preload the model so the first real action is instant. + func warmup() async + + /// Streaming variant: yields incremental output as it generates. The default + /// implementation wraps `run` as a single chunk, so a backend that doesn't + /// natively stream still conforms. + func runStreaming(_ action: AIAction, on text: String, protectedTerms: [String]) + -> AsyncThrowingStream +} + +extension AIEngine { + /// Convenience: run with no protected terms. + func run(_ action: AIAction, on text: String) async throws -> String { + try await run(action, on: text, protectedTerms: []) + } + + /// Default: no preload. + func warmup() async {} + + /// Default streaming: produce the full result, then emit it as one chunk. + func runStreaming(_ action: AIAction, on text: String, protectedTerms: [String]) + -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + do { + continuation.yield(try await run(action, on: text, protectedTerms: protectedTerms)) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + /// Convenience streaming overload with no protected terms. + func runStreaming(_ action: AIAction, on text: String) -> AsyncThrowingStream { + runStreaming(action, on: text, protectedTerms: []) + } +} + +enum AIEngineError: LocalizedError { + case modelNotReady + case generationFailed(String) + + var errorDescription: String? { + switch self { + case .modelNotReady: + return "The on-device model isn't ready yet. Finish the first-run download in Settings." + case .generationFailed(let why): + return "On-device rewrite failed: \(why)" + } + } +} diff --git a/Sources/GrammarGem/AI/AIPromptKit.swift b/Sources/GrammarGem/AI/AIPromptKit.swift new file mode 100644 index 0000000..0194c08 --- /dev/null +++ b/Sources/GrammarGem/AI/AIPromptKit.swift @@ -0,0 +1,111 @@ +import Foundation + +/// Backend-neutral prompt construction, generation parameters, and output +/// cleanup for Layer-2 actions. Extracted from `MLXEngine` so EVERY `AIEngine` +/// backend (MLX today, Ollama/llama.cpp next, the localhost extension server) is +/// guaranteed to produce identical prompts and identical output trimming — no +/// behavioral drift between runtimes — and so the personal dictionary / a future +/// style guide can inject protected vocabulary in exactly ONE place. +/// +/// Everything here is a pure function of its inputs (no MLX, no I/O), which also +/// makes per-action behavior unit-testable without a model present. +enum AIPrompts { + + /// The system prompt for an action. `protectedTerms` (personal-dictionary + /// entries + future style-guide vocabulary) are terms the model must never + /// alter, so AI rewrites stop "correcting" brand names and jargon. + static func systemPrompt(for action: AIAction, protectedTerms: [String] = []) -> String { + base(for: action) + protectedTermsClause(protectedTerms) + } + + private static func base(for action: AIAction) -> String { + switch action { + case .rewriteClarity: + return "Rewrite the user's text to be clearer and more concise. Preserve the " + + "original meaning and the author's voice. Output only the rewritten text." + case .rewrite: + return "Paraphrase the user's text. Keep the meaning but vary the wording and " + + "structure. Output only the rewritten text." + case .adjustTone(let tone): + return "Rewrite the user's text in a \(tone.rawValue) tone. Preserve the meaning. " + + "Output only the rewritten text." + case .ask(let instruction): + return "Apply this instruction to the user's text: \(instruction). " + + "Output only the resulting text." + case .translate(let language): + return "Translate the user's text into \(language). Output only the translation." + case .applyMode(let mode): + return mode.systemPrompt + } + } + + /// Appended to every system prompt: terms the model must reproduce verbatim + /// (brand names, jargon the user added to their personal dictionary). Capped + /// so a large dictionary can't blow the context window. + private static func protectedTermsClause(_ terms: [String]) -> String { + let cleaned = terms + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard !cleaned.isEmpty else { return "" } + let list = cleaned.prefix(50).map { "\"\($0)\"" }.joined(separator: ", ") + return " Never change, translate, or \"correct\" these exact terms — reproduce them " + + "verbatim, including capitalization: \(list)." + } + + /// Low temperature for faithful rewrites; a touch higher for free paraphrase. + static func temperature(for action: AIAction) -> Float { + switch action { + case .rewrite: return 0.5 + case .ask: return 0.4 + default: return 0.2 + } + } + + /// Generation token budget. Honors a Mode's `lengthCap` (in characters) for + /// short-form modes (Post = 280, Team Chat = 60) so we don't spend the full + /// 512-token default on a one-line message; falls back to the default otherwise. + static func maxTokens(for action: AIAction) -> Int { + let fallback = 512 + guard case .applyMode(let mode) = action, let cap = mode.lengthCap else { return fallback } + // ~1 token per 3 characters of English, plus headroom; clamp to a floor. + return min(fallback, max(64, cap / 3 + 48)) + } + + /// Strip a leading "Sure, here's …:" preamble and surrounding quotes that small + /// instruct models sometimes add, so the result drops cleanly into the field. + static func clean(_ s: String) -> String { + var out = s.trimmingCharacters(in: .whitespacesAndNewlines) + let lower = out.lowercased() + for prefix in ["sure, here", "sure! here", "here is", "here's", "certainly"] { + if lower.hasPrefix(prefix), let colon = out.firstIndex(of: ":") { + let after = out[out.index(after: colon)...] + .trimmingCharacters(in: .whitespacesAndNewlines) + if !after.isEmpty { out = after } + break + } + } + if out.count >= 2, out.first == "\"", out.last == "\"" { + out = String(out.dropFirst().dropLast()) + } + return out.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// The tone-classifier system prompt (kept here so any backend reuses it). + static func toneClassifierPrompt() -> String { + let options = Tone.allCases.map(\.rawValue).joined(separator: ", ") + return """ + You are a tone classifier. Read the user's text and respond with exactly ONE \ + word — the single best-matching tone from this list: \(options). \ + Output only that word, lowercase, no punctuation or explanation. + """ + } + + /// Parse the one-word tone classification output into a `Tone`. + static func parseTone(_ raw: String) -> Tone { + let token = raw.lowercased().trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + if let exact = Tone(rawValue: token) { return exact } + let lower = raw.lowercased() + for tone in Tone.allCases where lower.contains(tone.rawValue) { return tone } + return .professional + } +} diff --git a/Sources/GrammaGem/AI/CustomModesStore.swift b/Sources/GrammarGem/AI/CustomModesStore.swift similarity index 72% rename from Sources/GrammaGem/AI/CustomModesStore.swift rename to Sources/GrammarGem/AI/CustomModesStore.swift index 9c15c09..29dd876 100644 --- a/Sources/GrammaGem/AI/CustomModesStore.swift +++ b/Sources/GrammarGem/AI/CustomModesStore.swift @@ -8,8 +8,8 @@ final class CustomModesStore: ObservableObject { /// bundleID -> mode id (overrides the default App-Aware mapping). @Published private(set) var appOverrides: [String: String] - private let modesKey = "GrammaGem.customModes" - private let mapKey = "GrammaGem.appModeOverrides" + private let modesKey = "GrammarGem.customModes" + private let mapKey = "GrammarGem.appModeOverrides" private let d = UserDefaults.standard init() { @@ -25,19 +25,27 @@ final class CustomModesStore: ObservableObject { /// Built-in + custom modes (custom ones are always paid). func allModes() -> [WritingMode] { ModeRegistry.all + customModes } - func addCustom(name: String, prompt: String) { + /// Returns false (no-op) if the name or prompt is empty, or the name + /// collides with an existing built-in/custom mode — so an empty-prompt mode + /// can't slip through to the LLM as an empty system prompt. + @discardableResult + func addCustom(name: String, prompt: String) -> Bool { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } + let trimmedPrompt = prompt.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmedPrompt.isEmpty else { return false } + guard !allModes().contains(where: { $0.name.caseInsensitiveCompare(trimmed) == .orderedSame }) + else { return false } let mode = WritingMode( id: "custom-\(UUID().uuidString.prefix(8))", name: trimmed, - systemPrompt: prompt.trimmingCharacters(in: .whitespacesAndNewlines), + systemPrompt: trimmedPrompt, isPaid: true, lengthCap: nil, autoFormat: "Custom" ) customModes.append(mode) persistModes() + return true } func removeCustom(id: String) { diff --git a/Sources/GrammarGem/AI/MLXEngine.swift b/Sources/GrammarGem/AI/MLXEngine.swift new file mode 100644 index 0000000..d46c12f --- /dev/null +++ b/Sources/GrammarGem/AI/MLXEngine.swift @@ -0,0 +1,149 @@ +import Foundation +import MLXLMCommon +import MLXLLM + +/// Layer 2 — the real on-device LLM (rewrite / tone / "Ask" / translate), backed +/// by **MLX** via mlx-swift-examples (verified against tag 2.25.9; SPM package +/// "mlx-libraries", products MLXLLM + MLXLMCommon). +/// +/// Loads weights ONLY from a local on-disk snapshot (ModelManager's download +/// directory) — `ModelConfiguration(directory:)` never contacts the network, so +/// inference is fully on-device, honoring the privacy promise. +/// +/// API surface used (all confirmed in the 2.25.9 source): +/// ModelConfiguration(directory:) MLXLMCommon/ModelConfiguration.swift +/// LLMModelFactory.shared.loadContainer(...) MLXLLM/LLMModelFactory.swift +/// ModelContainer.perform { context in ... } MLXLMCommon/ModelContainer.swift +/// context.processor.prepare(input: UserInput(chat:)) MLXLMCommon/UserInput.swift +/// MLXLMCommon.generate(...) -> AsyncStream MLXLMCommon/Evaluate.swift +final class MLXEngine: AIEngine { + + /// Returns the local model snapshot directory if a complete model is present, + /// else nil. Owned by ModelManager; MLXEngine only loads + infers. + private let modelDirectoryProvider: () -> URL? + + /// Serializes the (expensive) first load and caches the container. + private let loader = ContainerLoader() + + init(modelDirectoryProvider: @escaping () -> URL?) { + self.modelDirectoryProvider = modelDirectoryProvider + } + + var isReady: Bool { modelDirectoryProvider() != nil } + + // MARK: - AIEngine + + func run(_ action: AIAction, on text: String, protectedTerms: [String]) async throws -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return text } + + let container = try await container() + let params = GenerateParameters(temperature: AIPrompts.temperature(for: action)) + let raw = try await complete( + system: AIPrompts.systemPrompt(for: action, protectedTerms: protectedTerms), + user: trimmed, + maxTokens: AIPrompts.maxTokens(for: action), + parameters: params, container: container) + return AIPrompts.clean(raw) + } + + /// Preload the model container so the first real action doesn't pay the + /// multi-second load. Safe to call repeatedly (the loader caches). + func warmup() async { + _ = try? await container() + } + + func runStreaming(_ action: AIAction, on text: String, protectedTerms: [String]) + -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continuation.yield(text); continuation.finish(); return } + do { + let container = try await container() + var params = GenerateParameters(temperature: AIPrompts.temperature(for: action)) + params.maxTokens = AIPrompts.maxTokens(for: action) + let system = AIPrompts.systemPrompt(for: action, protectedTerms: protectedTerms) + try await container.perform { context in + let input = try await context.processor.prepare( + input: UserInput(chat: [.system(system), .user(trimmed)])) + let stream = try MLXLMCommon.generate( + input: input, parameters: params, context: context) + for await item in stream { + if let chunk = item.chunk { continuation.yield(chunk) } + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + func detectTone(_ text: String) async -> Tone { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .professional } + do { + let container = try await container() + let raw = try await complete( + system: AIPrompts.toneClassifierPrompt(), user: trimmed, maxTokens: 8, + parameters: GenerateParameters(temperature: 0.0), container: container) + return AIPrompts.parseTone(raw) + } catch { + return .professional + } + } + + // MARK: - Loading + + private func container() async throws -> ModelContainer { + guard let dir = modelDirectoryProvider() else { throw AIEngineError.modelNotReady } + return try await loader.container(at: dir) + } + + // MARK: - Generation + + private func complete(system: String, user: String, maxTokens: Int, + parameters: GenerateParameters, + container: ModelContainer) async throws -> String { + do { + return try await container.perform { context in + let input = try await context.processor.prepare( + input: UserInput(chat: [.system(system), .user(user)])) + var params = parameters + params.maxTokens = maxTokens + var out = "" + let stream = try MLXLMCommon.generate( + input: input, parameters: params, context: context) + for await item in stream { + if let chunk = item.chunk { out += chunk } + } + return out + } + } catch let error as AIEngineError { + throw error + } catch { + throw AIEngineError.generationFailed(error.localizedDescription) + } + } + + // Prompt construction, temperature, token budget, and output cleanup now live + // in `AIPrompts` (backend-neutral; see AIPromptKit.swift) so every AIEngine + // backend produces identical prompts and identical output trimming. +} + +/// Caches one loaded `ModelContainer` (keyed by directory) and coalesces +/// concurrent first-loads. `ModelContainer` is itself an actor, so generation +/// calls through it are serialized safely. +private actor ContainerLoader { + private var cached: (url: URL, container: ModelContainer)? + + func container(at directory: URL) async throws -> ModelContainer { + if let cached, cached.url == directory { return cached.container } + let configuration = ModelConfiguration(directory: directory) + let container = try await LLMModelFactory.shared.loadContainer(configuration: configuration) + cached = (directory, container) + return container + } +} diff --git a/Sources/GrammaGem/AI/ModeRegistry.swift b/Sources/GrammarGem/AI/ModeRegistry.swift similarity index 100% rename from Sources/GrammaGem/AI/ModeRegistry.swift rename to Sources/GrammarGem/AI/ModeRegistry.swift diff --git a/Sources/GrammaGem/AI/ModelManager.swift b/Sources/GrammarGem/AI/ModelManager.swift similarity index 67% rename from Sources/GrammaGem/AI/ModelManager.swift rename to Sources/GrammarGem/AI/ModelManager.swift index fdcff5d..97f814e 100644 --- a/Sources/GrammaGem/AI/ModelManager.swift +++ b/Sources/GrammarGem/AI/ModelManager.swift @@ -14,7 +14,9 @@ final class ModelManager: ObservableObject { @Published private(set) var state: State = .notDownloaded @Published private(set) var statusText: String = "" - @Published var selectedRepo: String = AppConfig.Model.defaultRepo + @Published var selectedRepo: String = ModelManager.activeRepo() { + didSet { UserDefaults.standard.set(selectedRepo, forKey: Self.selectedRepoKey) } + } private var task: Task? private var generation = 0 @@ -23,20 +25,65 @@ final class ModelManager: ObservableObject { if isModelPresent(repo: selectedRepo) { state = .ready } } - var modelsDirectory: URL { - let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - return base.appendingPathComponent("GrammaGem/Models", isDirectory: true) - } + var modelsDirectory: URL { Self.modelsDirectory() } - func modelDir(_ repo: String) -> URL { - modelsDirectory.appendingPathComponent(repo.replacingOccurrences(of: "/", with: "_"), isDirectory: true) - } + func modelDir(_ repo: String) -> URL { Self.modelDir(repo) } /// Considered present once the completion marker is written. - func isModelPresent(repo: String) -> Bool { + func isModelPresent(repo: String) -> Bool { Self.isModelPresent(repo: repo) } + + // MARK: - Nonisolated path helpers + // + // The AI engine loads weights off the main actor, so the directory lookup must + // be callable from any thread. These are pure FileManager/URL ops (no actor + // state, no network) and back the @MainActor instance accessors above. + + nonisolated static func modelsDirectory() -> URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + return base.appendingPathComponent("GrammarGem/Models", isDirectory: true) + } + + nonisolated static func modelDir(_ repo: String) -> URL { + modelsDirectory().appendingPathComponent(repo.replacingOccurrences(of: "/", with: "_"), isDirectory: true) + } + + nonisolated static func isModelPresent(repo: String) -> Bool { FileManager.default.fileExists(atPath: modelDir(repo).appendingPathComponent(".complete").path) } + /// The local model directory iff a *complete* model is on disk, else nil — + /// fed to the AI engine as its network-free model source. + nonisolated static func completedModelDirectory(repo: String) -> URL? { + isModelPresent(repo: repo) ? modelDir(repo) : nil + } + + /// UserDefaults key backing the user's chosen model repo. + nonisolated static let selectedRepoKey = "GrammarGem.selectedModelRepo" + + /// The user's currently-selected repo, readable from ANY thread (it lives in + /// UserDefaults, which is thread-safe). The AI engine's model-directory + /// provider reads this so download, load, and `isReady` all agree on the SAME + /// repo — including the paid large model, which the old hardcoded-default + /// provider silently ignored. Falls back to the default repo when unset. + nonisolated static func activeRepo() -> String { + UserDefaults.standard.string(forKey: selectedRepoKey) ?? AppConfig.Model.defaultRepo + } + + /// Safe URL construction for the two Hugging Face endpoints. A malformed + /// repo/path yields nil → a thrown `ModelError`, never a force-unwrap crash. + nonisolated static func fileURL(repo: String, path: String) -> URL? { + var c = URLComponents(string: AppConfig.Model.huggingFaceBase) + c?.path = "/\(repo)/resolve/main/\(path)" + return c?.url + } + + nonisolated static func treeURL(repo: String) -> URL? { + var c = URLComponents(string: AppConfig.Model.huggingFaceBase) + c?.path = "/api/models/\(repo)/tree/main" + c?.queryItems = [URLQueryItem(name: "recursive", value: "1")] + return c?.url + } + /// Kick off (or resume to) a real download. Safe to call from the UI. func startDownload(repo: String? = nil) { let target = repo ?? selectedRepo @@ -74,7 +121,9 @@ final class ModelManager: ObservableObject { try Task.checkCancellation() set(gen, .downloading(progress: min(1, Double(done) / Double(total))), "Downloading \(file.path) (\(byteString(file.size)))…") - let url = URL(string: "\(AppConfig.Model.huggingFaceBase)/\(repo)/resolve/main/\(file.path)")! + guard let url = Self.fileURL(repo: repo, path: file.path) else { + throw ModelError.invalidURL(file.path) + } let (tmp, response) = try await URLSession.shared.download(from: url) if let http = response as? HTTPURLResponse, http.statusCode != 200 { throw ModelError.http(http.statusCode, file.path) @@ -130,7 +179,7 @@ final class ModelManager: ObservableObject { /// List the repo's files (+ sizes) from the Hugging Face tree API. private func fetchFileList(repo: String) async throws -> [(path: String, size: Int64)] { - let url = URL(string: "\(AppConfig.Model.huggingFaceBase)/api/models/\(repo)/tree/main?recursive=1")! + guard let url = Self.treeURL(repo: repo) else { throw ModelError.invalidURL("file list") } let (data, response) = try await URLSession.shared.data(from: url) if let http = response as? HTTPURLResponse, http.statusCode != 200 { throw ModelError.http(http.statusCode, "file list") @@ -148,11 +197,13 @@ final class ModelManager: ObservableObject { case empty case http(Int, String) case corrupt(String) + case invalidURL(String) var errorDescription: String? { switch self { case .empty: return "No model files were found for this repository." case .http(let code, let what): return "Download failed (\(code)) while fetching \(what)." case .corrupt(let what): return "Downloaded file looked wrong (\(what)); please retry." + case .invalidURL(let what): return "Couldn't build a valid download URL for \(what)." } } } diff --git a/Sources/GrammarGem/AI/OllamaEngine.swift b/Sources/GrammarGem/AI/OllamaEngine.swift new file mode 100644 index 0000000..842d870 --- /dev/null +++ b/Sources/GrammarGem/AI/OllamaEngine.swift @@ -0,0 +1,176 @@ +import Foundation + +/// A cross-platform Layer-2 backend that talks to a locally-running **Ollama** +/// server over HTTP. Conforms to the same `AIEngine` seam as `MLXEngine`, reuses +/// the backend-neutral `AIPrompts` (so prompts/output are identical across +/// runtimes), and depends only on `URLSession` — no MLX, no Metal — so it compiles +/// and runs anywhere, including Windows/Linux. This is what converts the AIEngine +/// abstraction from nominal to real and opens the non-Apple-Silicon market. +/// +/// Privacy is preserved: Ollama runs the model on the user's own machine on +/// loopback (127.0.0.1); no text leaves the device. +final class OllamaEngine: AIEngine { + private let base: URL + private let model: String + private let session: URLSession + + /// `isReady` is read synchronously by the coordinator but readiness for an HTTP + /// backend is an async probe, so we cache the last-known result behind a lock. + private let lock = NSLock() + private var cachedReady = false + + init(base: URL = AppConfig.Ollama.baseURL, + model: String = AppConfig.Ollama.defaultModel, + session: URLSession = .shared) { + self.base = base + self.model = model + self.session = session + } + + var isReady: Bool { + lock.lock(); defer { lock.unlock() } + return cachedReady + } + + private func setReady(_ value: Bool) { + lock.lock(); cachedReady = value; lock.unlock() + } + + // MARK: - AIEngine + + /// Probe the server and confirm the configured model is pulled. Readiness for + /// Ollama means "server up + model present" (vs. MLX's "weights on disk"). + func warmup() async { + setReady(await probeModelPresent()) + } + + func run(_ action: AIAction, on text: String, protectedTerms: [String]) async throws -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return text } + let raw = try await chat( + system: AIPrompts.systemPrompt(for: action, protectedTerms: protectedTerms), + user: trimmed, + temperature: AIPrompts.temperature(for: action), + maxTokens: AIPrompts.maxTokens(for: action)) + setReady(true) + return AIPrompts.clean(raw) + } + + func detectTone(_ text: String) async -> Tone { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .professional } + do { + let raw = try await chat( + system: AIPrompts.toneClassifierPrompt(), user: trimmed, + temperature: 0.0, maxTokens: 8) + return AIPrompts.parseTone(raw) + } catch { + return .professional + } + } + + func runStreaming(_ action: AIAction, on text: String, protectedTerms: [String]) + -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continuation.yield(text); continuation.finish(); return } + do { + try await streamChat( + system: AIPrompts.systemPrompt(for: action, protectedTerms: protectedTerms), + user: trimmed, + temperature: AIPrompts.temperature(for: action), + maxTokens: AIPrompts.maxTokens(for: action), + onChunk: { continuation.yield($0) }) + setReady(true) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + // MARK: - HTTP + + private func probeModelPresent() async -> Bool { + do { + let (data, response) = try await session.data(from: base.appendingPathComponent("api/tags")) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { return false } + let names = (try JSONDecoder().decode(TagsResponse.self, from: data)).models.map(\.name) + // Match exact or family (e.g. configured "qwen2.5:3b" vs a pulled "qwen2.5:3b-instruct"). + return names.contains(model) + || names.contains { $0.hasPrefix(model) || model.hasPrefix($0) } + } catch { + return false + } + } + + private func chat(system: String, user: String, + temperature: Float, maxTokens: Int) async throws -> String { + let request = try makeRequest(path: "api/chat", body: ChatRequest( + model: model, + messages: [.init(role: "system", content: system), .init(role: "user", content: user)], + stream: false, + options: .init(temperature: temperature, num_predict: maxTokens))) + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw AIEngineError.generationFailed("No HTTP response from Ollama at \(base.absoluteString)") + } + guard http.statusCode == 200 else { + throw AIEngineError.generationFailed("Ollama returned HTTP \(http.statusCode)") + } + return (try JSONDecoder().decode(ChatResponse.self, from: data)).message?.content ?? "" + } + + private func streamChat(system: String, user: String, + temperature: Float, maxTokens: Int, + onChunk: (String) -> Void) async throws { + let request = try makeRequest(path: "api/chat", body: ChatRequest( + model: model, + messages: [.init(role: "system", content: system), .init(role: "user", content: user)], + stream: true, + options: .init(temperature: temperature, num_predict: maxTokens))) + let (bytes, response) = try await session.bytes(for: request) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + throw AIEngineError.generationFailed("Ollama streaming request failed") + } + // Ollama streams newline-delimited JSON frames, one per token-ish chunk. + for try await line in bytes.lines { + guard let data = line.data(using: .utf8), + let frame = try? JSONDecoder().decode(ChatResponse.self, from: data), + let chunk = frame.message?.content, !chunk.isEmpty + else { continue } + onChunk(chunk) + } + } + + private func makeRequest(path: String, body: T) throws -> URLRequest { + var request = URLRequest(url: base.appendingPathComponent(path)) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + return request + } + + // MARK: - Wire types + + private struct ChatRequest: Encodable { + let model: String + let messages: [Message] + let stream: Bool + let options: Options + struct Message: Encodable { let role: String; let content: String } + struct Options: Encodable { let temperature: Float; let num_predict: Int } + } + + private struct ChatResponse: Decodable { + let message: Message? + struct Message: Decodable { let content: String } + } + + private struct TagsResponse: Decodable { + let models: [Model] + struct Model: Decodable { let name: String } + } +} diff --git a/Sources/GrammaGem/App/AppState.swift b/Sources/GrammarGem/App/AppState.swift similarity index 65% rename from Sources/GrammaGem/App/AppState.swift rename to Sources/GrammarGem/App/AppState.swift index a123b7d..f3eb745 100644 --- a/Sources/GrammaGem/App/AppState.swift +++ b/Sources/GrammarGem/App/AppState.swift @@ -27,8 +27,11 @@ final class AppState: ObservableObject { let snippets: SnippetStore let exclusions: Exclusions let liveMonitor: LiveMonitor + let updater: UpdaterManager private let capture: TextCapture let coordinator: TextReplacementCoordinator + /// Loopback bridge to the browser extension (opt-in; nil unless enabled). + private var localServer: LocalEngineServer? // UI state @Published var lastStatus: String = "Ready" @@ -45,7 +48,11 @@ final class AppState: ObservableObject { private init() { let engine = SystemGrammarEngine() grammar = engine - ai = MLXEngine(ready: true) + // The on-device LLM loads weights from ModelManager's local snapshot (if a + // complete download is present) — never the network. Resolved lazily on use. + // Reads the user's *selected* repo (default or paid large model) so the + // model that gets loaded matches the one that gets downloaded. + ai = AppState.makeAIEngine() permissions = Permissions() model = ModelManager() license = LicenseManager() @@ -61,8 +68,30 @@ final class AppState: ObservableObject { let excl = Exclusions() exclusions = excl liveMonitor = LiveMonitor(grammar: engine, detector: detector, exclusions: excl, capture: capture) + updater = UpdaterManager() + let dict = dictionary coordinator = TextReplacementCoordinator( - capture: capture, grammar: engine, ai: ai, gate: gate, detector: detector) + capture: capture, grammar: engine, ai: ai, gate: gate, detector: detector, + protectedTerms: { dict.entries }) + } + + // MARK: - Backend factory + + /// The single place the Layer-2 backend is chosen. Defaults to the MLX + /// on-device engine (Apple Silicon); a user/CI override selects the + /// cross-platform `OllamaEngine`. Adding a backend is a one-line branch here + /// instead of edits scattered across construction sites. The MLX provider + /// reads the user's *selected* repo so the loaded model matches the downloaded + /// one (incl. the paid large model). + static func makeAIEngine() -> AIEngine { + let override = ProcessInfo.processInfo.environment["GRAMMARGEM_AI_BACKEND"] + ?? UserDefaults.standard.string(forKey: "GrammarGem.aiBackend") + if override == "ollama" { + return OllamaEngine() + } + return MLXEngine(modelDirectoryProvider: { + ModelManager.completedModelDirectory(repo: ModelManager.activeRepo()) + }) } /// Called once at launch (from the AppDelegate). @@ -95,10 +124,19 @@ final class AppState: ObservableObject { // Auto-install the on-device AI model in the background on first use, so // the AI features are ready without the user hunting for a button. The - // download is resumable/cancelable from the AI Model screen. - if !model.isModelPresent(repo: model.selectedRepo) { + // download is resumable/cancelable from the AI Model screen. If a model is + // already present, warm it up now so the first AI action is instant. + if model.isModelPresent(repo: model.selectedRepo) { + Task { [weak self] in await self?.ai.warmup() } + } else { model.startDownload() } + // Warm the model up as soon as a (first-run) download completes. + model.$state.dropFirst() + .sink { [weak self] state in + if case .ready = state { Task { [weak self] in await self?.ai.warmup() } } + } + .store(in: &cancellables) permissions.refresh() appAwareEnabled = gate.appAwareEnabled @@ -109,7 +147,77 @@ final class AppState: ObservableObject { } Task { await license.validateIfNeeded() } - Log.app.info("GrammaGem started. Licensed: \(self.license.isLicensed, privacy: .public)") + startLocalServerIfEnabled() + Log.app.info("GrammarGem started. Licensed: \(self.license.isLicensed, privacy: .public)") + } + + // MARK: - Browser-extension bridge (opt-in) + + /// Start the loopback engine server only when the user has opted in, so the app + /// ships no listening socket by default. The server reuses the SAME engines, + /// prompts, and entitlement gate as the menu-bar app. + private func startLocalServerIfEnabled() { + let enabled = ProcessInfo.processInfo.environment["GRAMMARGEM_LOCAL_SERVER"] == "1" + || UserDefaults.standard.bool(forKey: AppConfig.LocalServer.enableKey) + guard enabled, localServer == nil else { return } + + let server = LocalEngineServer( + port: AppConfig.LocalServer.port, + token: LocalEngineServer.loadOrCreateToken(), + grammarHandler: { [weak self] text in + await MainActor.run { + guard let self else { return (text, []) } + let corrected = self.grammar.correct(text) + let suggestions = self.grammar.check(text).map { + LocalEngineServer.WireSuggestion( + location: $0.location, length: $0.length, + original: $0.original, replacement: $0.replacement, + kind: $0.kind.rawValue, message: $0.message) + } + return (corrected, suggestions) + } + }, + aiHandler: { [weak self] (req: LocalEngineServer.AIRequest) async -> LocalEngineServer.AIOutcome in + guard let self else { return .error("GrammarGem is shutting down.") } + return await self.handleServerAI(req) + }, + aiStreamHandler: { [weak self] (req, onChunk) -> LocalEngineServer.AIOutcome in + guard let self else { return .error("GrammarGem is shutting down.") } + return await self.handleServerAIStream(req, onChunk: onChunk) + }) + server.start() + localServer = server + } + + /// Run an AI action requested by the extension — same gate + model as the app. + private func handleServerAI(_ req: LocalEngineServer.AIRequest) async -> LocalEngineServer.AIOutcome { + if case .denied(let reason) = gate.authorizeAI(req.action) { return .error(reason) } + guard ai.isReady else { return .error(AIEngineError.modelNotReady.localizedDescription) } + do { + let out = try await ai.run(req.action, on: req.text, protectedTerms: dictionary.entries) + gate.recordAIActionUsed() + return .ok(out) + } catch { + return .error(error.localizedDescription) + } + } + + /// Streaming variant for the extension: yields tokens as they generate. + private func handleServerAIStream(_ req: LocalEngineServer.AIRequest, + onChunk: @escaping (String) -> Void) async -> LocalEngineServer.AIOutcome { + if case .denied(let reason) = gate.authorizeAI(req.action) { return .error(reason) } + guard ai.isReady else { return .error(AIEngineError.modelNotReady.localizedDescription) } + var accumulated = "" + do { + for try await chunk in ai.runStreaming(req.action, on: req.text, protectedTerms: dictionary.entries) { + accumulated += chunk + onChunk(chunk) + } + gate.recordAIActionUsed() + return .ok(accumulated) + } catch { + return .error(error.localizedDescription) + } } // MARK: - Hotkey handling @@ -179,19 +287,39 @@ final class AppState: ObservableObject { usage.recordCorrection(words: 1) } - /// Pause/resume all of GrammaGem (hotkeys + live monitoring). + /// Hide a single suggestion without changing the user's text. + func dismissSuggestion(_ suggestion: Suggestion, in field: DetectedField) { + liveMonitor.dismiss(suggestion, in: field) + } + + /// Add a flagged word to the personal dictionary so it's never corrected + /// again (gated by the free cap), then re-scan so it disappears now. + @discardableResult + func addWordToDictionary(_ word: String) -> FeatureGate.Decision { + let decision = addDictionaryEntry(word) + switch decision { + case .allowed: + lastStatus = "Added “\(word)” to your dictionary" + liveMonitor.refreshSoon() + case .denied(let reason): + lastStatus = reason + } + return decision + } + + /// Pause/resume all of GrammarGem (hotkeys + live monitoring). func togglePause() { isPaused.toggle() liveMonitor.setPaused(isPaused) lastStatus = isPaused ? "Paused" : "Ready" } - /// A reason GrammaGem should stay out of the way right now, or nil. + /// A reason GrammarGem should stay out of the way right now, or nil. private func blockReason() -> String? { - if isPaused { return "GrammaGem is paused." } + if isPaused { return "GrammarGem is paused." } let front = detector.frontmost() if exclusions.isBlocked(bundleID: front?.bundleID, domain: detector.frontmostDomain()) { - return "GrammaGem is off for \(front?.name ?? "this app")." + return "GrammarGem is off for \(front?.name ?? "this app")." } return nil } @@ -256,9 +384,9 @@ final class AppState: ObservableObject { if onboardingWindow == nil { let host = NSHostingController(rootView: OnboardingView().environmentObject(self)) let win = NSWindow(contentViewController: host) - win.title = "Welcome to GrammaGem" + win.title = "Welcome to GrammarGem" win.styleMask = [.titled, .closable, .fullSizeContentView] - win.setContentSize(NSSize(width: 540, height: 600)) + win.setContentSize(NSSize(width: 560, height: 720)) win.isReleasedWhenClosed = false win.center() onboardingWindow = win @@ -285,7 +413,7 @@ final class AppState: ObservableObject { .environmentObject(liveMonitor) let host = NSHostingController(rootView: root) let win = NSWindow(contentViewController: host) - win.title = "GrammaGem" + win.title = "GrammarGem" win.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] win.setContentSize(NSSize(width: 940, height: 660)) win.minSize = NSSize(width: 820, height: 560) @@ -323,7 +451,7 @@ final class AppState: ObservableObject { if askWindow == nil { let host = NSHostingController(rootView: AskView().environmentObject(self)) let win = NSWindow(contentViewController: host) - win.title = "Ask GrammaGem" + win.title = "Ask GrammarGem" win.styleMask = [.titled, .closable] win.setContentSize(NSSize(width: 460, height: 220)) win.isReleasedWhenClosed = false diff --git a/Sources/GrammaGem/App/AskView.swift b/Sources/GrammarGem/App/AskView.swift similarity index 97% rename from Sources/GrammaGem/App/AskView.swift rename to Sources/GrammarGem/App/AskView.swift index ce02754..618c873 100644 --- a/Sources/GrammaGem/App/AskView.swift +++ b/Sources/GrammarGem/App/AskView.swift @@ -8,7 +8,7 @@ struct AskView: View { var body: some View { VStack(alignment: .leading, spacing: 14) { - Text("Ask GrammaGem") + Text("Ask GrammarGem") .font(.headline) Text("Acts on the text selected in the frontmost app. Runs on-device.") .font(.caption) diff --git a/Sources/GrammarGem/App/GrammarGemApp.swift b/Sources/GrammarGem/App/GrammarGemApp.swift new file mode 100644 index 0000000..e3726ee --- /dev/null +++ b/Sources/GrammarGem/App/GrammarGemApp.swift @@ -0,0 +1,76 @@ +import SwiftUI +import AppKit + +/// Process entry point. A hidden `--mlx-selftest` flag loads the on-device model +/// and runs one real generation (verifies the MLX runtime + metallib headlessly), +/// then exits. Otherwise the normal menu-bar app launches. +@main +enum GrammarGemMain { + static func main() { + if CommandLine.arguments.contains("--mlx-selftest") { + MLXSelfTest.run() + return + } + if let i = CommandLine.arguments.firstIndex(of: "--update-selftest") { + let feed = i + 1 < CommandLine.arguments.count ? CommandLine.arguments[i + 1] : "" + UpdaterSelfTest.run(feedURL: feed) + return + } + GrammarGemApp.main() + } +} + +/// Headless verification of the on-device LLM (used by CI / the build pipeline). +enum MLXSelfTest { + static func run() { + let sample = "me and him was going to the store for to buy some milks" + let engine = MLXEngine(modelDirectoryProvider: { + ModelManager.completedModelDirectory(repo: ModelManager.activeRepo()) + }) + guard engine.isReady else { + FileHandle.standardError.write(Data("SELFTEST_FAIL: model not present\n".utf8)) + exit(2) + } + let sem = DispatchSemaphore(value: 0) + var line = "SELFTEST_FAIL: unknown" + Task.detached { + do { line = "SELFTEST_OK: " + (try await engine.run(.rewriteClarity, on: sample)) } + catch { line = "SELFTEST_FAIL: \(error.localizedDescription)" } + sem.signal() + } + sem.wait() + print(line) + exit(line.hasPrefix("SELFTEST_OK") ? 0 : 1) + } +} + +/// App entry point. A menu-bar (`LSUIElement`) SwiftUI app: the only persistent +/// scene is the `MenuBarExtra`; Settings uses the standard scene; Onboarding and +/// Ask are AppKit-hosted windows opened on demand (see `AppState`). +struct GrammarGemApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @StateObject private var app = AppState.shared + + var body: some Scene { + MenuBarExtra { + MenuBarContent() + .environmentObject(app) + } label: { + MenuBarLabel() + } + .menuBarExtraStyle(.window) + + Settings { + SettingsView() + .environmentObject(app) + .frame(width: 560, height: 520) + } + } +} + +/// Runs launch-time wiring (hotkeys, permissions, license validation). +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + AppState.shared.start() + } +} diff --git a/Sources/GrammaGem/App/MainWindow/AccountViews.swift b/Sources/GrammarGem/App/MainWindow/AccountViews.swift similarity index 96% rename from Sources/GrammaGem/App/MainWindow/AccountViews.swift rename to Sources/GrammarGem/App/MainWindow/AccountViews.swift index 6304145..e77fec2 100644 --- a/Sources/GrammaGem/App/MainWindow/AccountViews.swift +++ b/Sources/GrammarGem/App/MainWindow/AccountViews.swift @@ -8,7 +8,7 @@ struct GeneralView: View { @EnvironmentObject private var permissions: Permissions var body: some View { - DetailScaffold(title: "General", subtitle: "Make GrammaGem feel like yours.") { + DetailScaffold(title: "General", subtitle: "Make GrammarGem feel like yours.") { Card { Toggle("Launch at login", isOn: $prefs.launchAtLogin) Divider() @@ -134,7 +134,7 @@ struct AboutView: View { .frame(width: 56, height: 56) .background(GG.emerald.opacity(0.12), in: RoundedRectangle(cornerRadius: 14)) VStack(alignment: .leading, spacing: 2) { - Text("GrammaGem").font(.title2.bold()) + Text("GrammarGem").font(.title2.bold()) Text("Version \(AppConfig.appVersion)").foregroundStyle(.secondary) Text("The private, on-device writing assistant for Mac.") .font(.callout).foregroundStyle(.secondary) @@ -153,7 +153,7 @@ struct AboutView: View { Card { Text("Open source").font(.headline) - Text("GrammaGem is open source under the MIT License — a privacy tool you can inspect.") + Text("GrammarGem is open source under the MIT License — a privacy tool you can inspect.") .font(.callout).foregroundStyle(.secondary) } diff --git a/Sources/GrammaGem/App/MainWindow/DashboardView.swift b/Sources/GrammarGem/App/MainWindow/DashboardView.swift similarity index 83% rename from Sources/GrammaGem/App/MainWindow/DashboardView.swift rename to Sources/GrammarGem/App/MainWindow/DashboardView.swift index 3b2206b..b57c192 100644 --- a/Sources/GrammaGem/App/MainWindow/DashboardView.swift +++ b/Sources/GrammarGem/App/MainWindow/DashboardView.swift @@ -30,6 +30,15 @@ struct DashboardView: View { StatTile(value: "\(usage.data.totalCorrections)", label: "Corrections made", systemImage: "checkmark.seal.fill", tint: GG.emerald) StatTile(value: timeSaved, label: "Time saved (est.)", systemImage: "clock.fill", tint: .primary) + + StatTile( + value: usage.currentStreak > 0 ? "\(usage.currentStreak)d" : "—", + label: "Day streak", systemImage: "flame.fill", + tint: usage.currentStreak > 0 ? .orange : .secondary) + StatTile( + value: "$\(usage.subscriptionAvoidedUSD)", + label: "Subscription not paid", systemImage: "dollarsign.circle.fill", + tint: GG.emerald) } // Setup health @@ -61,6 +70,19 @@ struct DashboardView: View { .font(.caption).foregroundStyle(.secondary) } + // Next milestone + Card { + let milestone = usage.nextMilestone + HStack { + Text("Next milestone").font(.headline) + Spacer() + Text(milestone.label).font(.caption).foregroundStyle(.secondary) + } + ProgressView(value: milestone.progress).tint(GG.emerald) + Text("\(usage.data.totalCorrections) corrections so far — keep the streak alive.") + .font(.caption).foregroundStyle(.secondary) + } + // Quick actions Card { Text("Quick actions").font(.headline) @@ -69,7 +91,7 @@ struct DashboardView: View { Label("Fix selection", systemImage: "checkmark.circle") } Button { app.showAsk() } label: { - Label("Ask GrammaGem", systemImage: "sparkles") + Label("Ask GrammarGem", systemImage: "sparkles") } if !license.isLicensed { Spacer() diff --git a/Sources/GrammaGem/App/MainWindow/DataViews.swift b/Sources/GrammarGem/App/MainWindow/DataViews.swift similarity index 98% rename from Sources/GrammaGem/App/MainWindow/DataViews.swift rename to Sources/GrammarGem/App/MainWindow/DataViews.swift index de9c92d..4b35b4b 100644 --- a/Sources/GrammaGem/App/MainWindow/DataViews.swift +++ b/Sources/GrammarGem/App/MainWindow/DataViews.swift @@ -12,7 +12,7 @@ struct DictionaryView: View { var body: some View { DetailScaffold( title: "Personal Dictionary", - subtitle: "Words GrammaGem should never “correct” — names, brands, jargon." + subtitle: "Words GrammarGem should never “correct” — names, brands, jargon." ) { Card { HStack { diff --git a/Sources/GrammaGem/App/MainWindow/DevicesView.swift b/Sources/GrammarGem/App/MainWindow/DevicesView.swift similarity index 97% rename from Sources/GrammaGem/App/MainWindow/DevicesView.swift rename to Sources/GrammarGem/App/MainWindow/DevicesView.swift index d2147f9..d39a93e 100644 --- a/Sources/GrammaGem/App/MainWindow/DevicesView.swift +++ b/Sources/GrammarGem/App/MainWindow/DevicesView.swift @@ -83,7 +83,7 @@ struct DevicesView: View { HStack { Link("Open license portal", destination: AppConfig.modelPortalURL) Spacer() - Text("Add a Mac: install GrammaGem there and paste the same key.") + Text("Add a Mac: install GrammarGem there and paste the same key.") .font(.caption).foregroundStyle(.secondary) } } diff --git a/Sources/GrammaGem/App/MainWindow/ExclusionsView.swift b/Sources/GrammarGem/App/MainWindow/ExclusionsView.swift similarity index 95% rename from Sources/GrammaGem/App/MainWindow/ExclusionsView.swift rename to Sources/GrammarGem/App/MainWindow/ExclusionsView.swift index 74e4667..0c005d8 100644 --- a/Sources/GrammaGem/App/MainWindow/ExclusionsView.swift +++ b/Sources/GrammarGem/App/MainWindow/ExclusionsView.swift @@ -8,12 +8,12 @@ struct ExclusionsView: View { var body: some View { DetailScaffold( title: "Page Blocker", - subtitle: "Keep GrammaGem out of the way in sensitive apps and sites." + subtitle: "Keep GrammarGem out of the way in sensitive apps and sites." ) { Card { Label("How it works", systemImage: "hand.raised") .font(.headline) - Text("When the frontmost app or website is on these lists, GrammaGem stops monitoring and the hotkeys do nothing — useful for password managers, banking, and private work.") + Text("When the frontmost app or website is on these lists, GrammarGem stops monitoring and the hotkeys do nothing — useful for password managers, banking, and private work.") .font(.callout).foregroundStyle(.secondary) } diff --git a/Sources/GrammaGem/App/MainWindow/LiveCheckView.swift b/Sources/GrammarGem/App/MainWindow/LiveCheckView.swift similarity index 61% rename from Sources/GrammaGem/App/MainWindow/LiveCheckView.swift rename to Sources/GrammarGem/App/MainWindow/LiveCheckView.swift index 7767c5d..dad4369 100644 --- a/Sources/GrammaGem/App/MainWindow/LiveCheckView.swift +++ b/Sources/GrammarGem/App/MainWindow/LiveCheckView.swift @@ -34,22 +34,32 @@ struct LiveCheckView: View { } private var header: some View { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text(summary).font(.headline) - Text(monitor.activeAppName.isEmpty - ? "Checking text areas on screen" - : "In \(monitor.activeAppName) · \(monitor.scannedCount) text area\(monitor.scannedCount == 1 ? "" : "s") checked") - .font(.caption).foregroundStyle(.secondary) - } - Spacer() - Button { monitor.refreshSoon() } label: { Image(systemName: "arrow.clockwise") } - .help("Re-scan now") - if monitor.issueCount > 0 { - Button { app.fixAllDetected() } label: { - Label("Fix everything", systemImage: "checkmark.circle.fill") + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + ScoreRing(score: monitor.writingScore) + VStack(alignment: .leading, spacing: 2) { + Text(summary).font(.headline) + Text(contextLine).font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button { monitor.refreshSoon() } label: { Image(systemName: "arrow.clockwise") } + .help("Re-scan now") + if monitor.issueCount > 0 { + Button { app.fixAllDetected() } label: { + Label("Fix everything", systemImage: "checkmark.circle.fill") + } + .buttonStyle(.borderedProminent) + } + } + if monitor.issueCount > 0 { + HStack(spacing: 8) { + ForEach(IssueBucket.allCases) { bucket in + if let n = monitor.bucketCounts[bucket], n > 0 { + BucketChip(bucket: bucket, count: n) + } + } + Spacer() } - .buttonStyle(.borderedProminent) } } .padding(16) @@ -57,10 +67,16 @@ struct LiveCheckView: View { private var summary: String { let issues = monitor.issueCount - if issues == 0 { return "No issues found" } + if issues == 0 { return "Looks clean" } return "\(issues) issue\(issues == 1 ? "" : "s") across \(monitor.fields.count) text area\(monitor.fields.count == 1 ? "" : "s")" } + private var contextLine: String { + monitor.activeAppName.isEmpty + ? "Checking text areas on screen" + : "In \(monitor.activeAppName) · \(monitor.scannedCount) text area\(monitor.scannedCount == 1 ? "" : "s") checked" + } + private var emptyState: some View { VStack(spacing: 10) { Image(systemName: "checkmark.seal.fill").font(.system(size: 42)).foregroundStyle(GG.emerald) @@ -112,9 +128,21 @@ private struct FieldCard: View { } } Spacer() + if s.kind == .spelling { + Button { app.addWordToDictionary(s.original) } label: { + Image(systemName: "book.closed") + } + .controlSize(.small).buttonStyle(.borderless) + .help("Add “\(s.original)” to your dictionary") + } + Button { app.dismissSuggestion(s, in: field) } label: { + Image(systemName: "xmark") + } + .controlSize(.small).buttonStyle(.borderless) + .help("Dismiss this suggestion") if s.replacement != s.original && !s.replacement.isEmpty { Button("Apply") { app.applySuggestion(s, in: field) } - .controlSize(.small) + .controlSize(.small).buttonStyle(.borderedProminent) } } } @@ -141,6 +169,14 @@ private struct FieldCard: View { } } + fileprivate static func color(for bucket: IssueBucket) -> Color { + switch bucket { + case .correctness: return .red + case .clarity: return .blue + case .polish: return GG.gold + } + } + /// Build an attributed string with each issue span tinted. private func highlighted(_ text: String, _ suggestions: [Suggestion]) -> AttributedString { let ns = text as NSString @@ -167,3 +203,40 @@ private struct FieldCard: View { return result } } + +/// A compact 0–100 cleanliness gauge. Green when clean, amber/orange as issues +/// pile up — a meter the user can watch climb as they accept fixes. +private struct ScoreRing: View { + let score: Int + var body: some View { + ZStack { + Circle().stroke(Color.primary.opacity(0.1), lineWidth: 5) + Circle() + .trim(from: 0, to: CGFloat(max(0, min(100, score))) / 100) + .stroke(color, style: StrokeStyle(lineWidth: 5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.easeOut(duration: 0.35), value: score) + Text("\(score)").font(.system(size: 15, weight: .bold, design: .rounded)) + } + .frame(width: 44, height: 44) + .help("Writing score for the text on screen") + } + + private var color: Color { + score >= 90 ? GG.emerald : (score >= 70 ? GG.gold : .orange) + } +} + +/// A tappable-looking count chip per issue bucket (Correctness / Clarity / Polish). +private struct BucketChip: View { + let bucket: IssueBucket + let count: Int + var body: some View { + HStack(spacing: 5) { + Circle().fill(FieldCard.color(for: bucket)).frame(width: 7, height: 7) + Text("\(bucket.title) \(count)").font(.caption.weight(.medium)) + } + .padding(.horizontal, 9).padding(.vertical, 4) + .background(FieldCard.color(for: bucket).opacity(0.12), in: Capsule()) + } +} diff --git a/Sources/GrammaGem/App/MainWindow/MainWindowView.swift b/Sources/GrammarGem/App/MainWindow/MainWindowView.swift similarity index 98% rename from Sources/GrammaGem/App/MainWindow/MainWindowView.swift rename to Sources/GrammarGem/App/MainWindow/MainWindowView.swift index bc81ab6..2b2d224 100644 --- a/Sources/GrammaGem/App/MainWindow/MainWindowView.swift +++ b/Sources/GrammarGem/App/MainWindow/MainWindowView.swift @@ -59,7 +59,7 @@ enum MainSection: String, CaseIterable, Identifiable { ] } -/// The full GrammaGem management window: a clean sidebar + detail layout. +/// The full GrammarGem management window: a clean sidebar + detail layout. struct MainWindowView: View { @State private var selection: MainSection? diff --git a/Sources/GrammaGem/App/MainWindow/SystemViews.swift b/Sources/GrammarGem/App/MainWindow/SystemViews.swift similarity index 98% rename from Sources/GrammaGem/App/MainWindow/SystemViews.swift rename to Sources/GrammarGem/App/MainWindow/SystemViews.swift index 41b5d08..bec3bb8 100644 --- a/Sources/GrammaGem/App/MainWindow/SystemViews.swift +++ b/Sources/GrammarGem/App/MainWindow/SystemViews.swift @@ -17,7 +17,7 @@ struct ShortcutsView: View { combo: $prefs.fixHotkey) Divider() shortcutRow( - name: "Ask GrammaGem", + name: "Ask GrammarGem", detail: "Rewrite with an instruction", combo: $prefs.askHotkey) } @@ -141,7 +141,7 @@ struct PrivacyView: View { } Card { - Text("The only times GrammaGem uses the network").font(.headline) + Text("The only times GrammarGem uses the network").font(.headline) networkRow("One-time AI model download", "Hugging Face, on first run") Divider() networkRow("License activation & checks", "To enforce your device cap") diff --git a/Sources/GrammaGem/App/MainWindow/WritingModesView.swift b/Sources/GrammarGem/App/MainWindow/WritingModesView.swift similarity index 97% rename from Sources/GrammaGem/App/MainWindow/WritingModesView.swift rename to Sources/GrammarGem/App/MainWindow/WritingModesView.swift index e25b9f2..f981b27 100644 --- a/Sources/GrammaGem/App/MainWindow/WritingModesView.swift +++ b/Sources/GrammarGem/App/MainWindow/WritingModesView.swift @@ -15,7 +15,7 @@ struct WritingModesView: View { var body: some View { DetailScaffold( title: "Writing Modes", - subtitle: "Tune the tone for every context — or let GrammaGem pick it for you." + subtitle: "Tune the tone for every context — or let GrammarGem pick it for you." ) { builtInCard customCard @@ -88,7 +88,7 @@ struct WritingModesView: View { private var appAwareCard: some View { Card { HStack { Text("App-aware switching").font(.headline); if !paid { UpgradeBadge() } } - Text("Map an app to a Mode and GrammaGem applies it automatically when that app is in front.") + Text("Map an app to a Mode and GrammarGem applies it automatically when that app is in front.") .font(.callout).foregroundStyle(.secondary) if !customModes.appOverrides.isEmpty { diff --git a/Sources/GrammaGem/App/MenuBarContent.swift b/Sources/GrammarGem/App/MenuBarContent.swift similarity index 76% rename from Sources/GrammaGem/App/MenuBarContent.swift rename to Sources/GrammarGem/App/MenuBarContent.swift index ad71c13..4c00415 100644 --- a/Sources/GrammaGem/App/MenuBarContent.swift +++ b/Sources/GrammarGem/App/MenuBarContent.swift @@ -24,16 +24,22 @@ struct MenuBarLabel: View { struct MenuBarContent: View { @EnvironmentObject private var app: AppState @ObservedObject private var monitor: LiveMonitor + @ObservedObject private var updater: UpdaterManager @Environment(\.openSettings) private var openSettings init() { _monitor = ObservedObject(wrappedValue: AppState.shared.liveMonitor) + _updater = ObservedObject(wrappedValue: AppState.shared.updater) } var body: some View { VStack(alignment: .leading, spacing: 10) { header + if let version = updater.updateAvailableVersion { + updateBanner(version: version) + } + Divider() liveStatus @@ -43,7 +49,7 @@ struct MenuBarContent: View { actionRow(title: "Fix selection", shortcut: app.preferences.fixHotkey.display) { Task { await app.runFix() } } - actionRow(title: "Ask GrammaGem…", shortcut: app.preferences.askHotkey.display) { + actionRow(title: "Ask GrammarGem…", shortcut: app.preferences.askHotkey.display) { app.showAsk() } if app.gate.appAwareEnabled { @@ -51,20 +57,22 @@ struct MenuBarContent: View { Task { await app.runAppAwareRewrite() } } } - actionRow(title: app.isPaused ? "Resume GrammaGem" : "Pause GrammaGem", shortcut: nil) { + actionRow(title: app.isPaused ? "Resume GrammarGem" : "Pause GrammarGem", shortcut: nil) { app.togglePause() } Divider() actionRow(title: "Show live issues…", shortcut: nil) { app.showLiveCheck() } - actionRow(title: "Open GrammaGem…", shortcut: nil) { app.showMainWindow() } + actionRow(title: "Open GrammarGem…", shortcut: nil) { app.showMainWindow() } actionRow(title: "Manage devices…", shortcut: nil) { app.showMainWindow(select: .devices) } actionRow(title: "Page blocker…", shortcut: nil) { app.showMainWindow(select: .exclusions) } + actionRow(title: "Check for Updates…", shortcut: nil) { app.updater.checkForUpdates() } Divider() usageRow + valueRow Divider() @@ -82,11 +90,27 @@ struct MenuBarContent: View { .frame(width: 290) } + /// Shown when Sparkle has found a newer signed build on grammargem.com. + private func updateBanner(version: String) -> some View { + HStack(spacing: 8) { + Image(systemName: "arrow.down.circle.fill").foregroundStyle(GG.emerald) + VStack(alignment: .leading, spacing: 1) { + Text("Update available").font(.callout.weight(.semibold)) + Text("Version \(version) is ready to install").font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button("Update") { app.updater.checkForUpdates() } + .buttonStyle(.borderedProminent).controlSize(.small) + } + .padding(8) + .background(GG.emerald.opacity(0.10), in: RoundedRectangle(cornerRadius: 8)) + } + private var header: some View { HStack(spacing: 8) { Image(systemName: "pencil.and.scribble").foregroundStyle(.tint) VStack(alignment: .leading, spacing: 1) { - Text("GrammaGem").font(.headline) + Text("GrammarGem").font(.headline) Text(app.license.tier.displayName + (app.license.isLicensed ? " · licensed" : " · free")) .font(.caption).foregroundStyle(.secondary) } @@ -152,6 +176,16 @@ struct MenuBarContent: View { } } + /// One always-visible value reminder: the streak + lifetime corrections. + @ViewBuilder + private var valueRow: some View { + if app.usage.currentStreak > 1 { + Label("\(app.usage.currentStreak)-day streak · \(app.usage.data.totalCorrections) corrections", + systemImage: "flame.fill") + .font(.caption).foregroundStyle(.secondary) + } + } + private func actionRow(title: String, shortcut: String?, action: @escaping () -> Void) -> some View { Button(action: action) { HStack { diff --git a/Sources/GrammarGem/App/OnboardingView.swift b/Sources/GrammarGem/App/OnboardingView.swift new file mode 100644 index 0000000..b926043 --- /dev/null +++ b/Sources/GrammarGem/App/OnboardingView.swift @@ -0,0 +1,220 @@ +import SwiftUI + +/// First-run onboarding: explains why GrammarGem needs Accessibility, deep-links +/// to System Settings, and advances automatically the moment permission is +/// granted (even if granted directly in System Settings). +struct OnboardingView: View { + @EnvironmentObject private var app: AppState + @ObservedObject private var permissions: Permissions + @ObservedObject private var model: ModelManager + + init() { + _permissions = ObservedObject(wrappedValue: AppState.shared.permissions) + _model = ObservedObject(wrappedValue: AppState.shared.model) + } + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 6) { + Text("Welcome to GrammarGem") + .font(.largeTitle.bold()) + Text("A private, on-device writing assistant that works in every app.") + .foregroundStyle(.secondary) + } + + // The rigged win: prove the value in one click, before any ask. + VStack(alignment: .leading, spacing: 10) { + Label("See it work", systemImage: "wand.and.stars").font(.headline) + DemoStep() + } + .padding(16) + .background(GG.emerald.opacity(0.06), in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(GG.emerald.opacity(0.25))) + + stepCard( + number: 1, + title: "Turn it on everywhere", + body: "GrammarGem reads the text you select and writes the correction back. macOS requires Accessibility permission for this. Your text is processed on-device and never leaves your Mac.", + granted: permissions.accessibilityTrusted + ) { + if permissions.accessibilityTrusted { + Label("Permission granted", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + HStack { + Button("Open System Settings") { permissions.openAccessibilitySettings() } + Button("Request permission") { permissions.requestAccessibility() } + } + Text("This updates automatically once you flip GrammarGem on in Accessibility.") + .font(.caption).foregroundStyle(.secondary) + } + } + + stepCard( + number: 2, + title: "Download the on-device model (optional)", + body: "Grammar & spelling work instantly with no download. The AI rewrite/tone/Ask features use a small model downloaded once from Hugging Face — then everything runs offline.", + granted: model.state == .ready + ) { + ModelDownloadControls() + } + } + .padding(28) + } + + Divider() + + HStack { + Label("Yours once. No subscription, nothing leaves your Mac.", systemImage: "lock.shield") + .font(.caption).foregroundStyle(.secondary) + Spacer() + Button(permissions.accessibilityTrusted ? "Done" : "Continue without permission") { + app.showOnboardingDismiss() + } + .keyboardShortcut(.defaultAction) + } + .padding(.horizontal, 28).padding(.vertical, 14) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onAppear { + permissions.startPollingUntilGranted() + } + .onDisappear { + permissions.stopPolling() + } + } + + private func stepCard( + number: Int, title: String, body: String, granted: Bool, + @ViewBuilder controls: () -> Controls + ) -> some View { + HStack(alignment: .top, spacing: 14) { + ZStack { + Circle().fill(granted ? Color.green : Color.accentColor.opacity(0.15)) + .frame(width: 30, height: 30) + if granted { + Image(systemName: "checkmark").foregroundStyle(.white).font(.caption.bold()) + } else { + Text("\(number)").font(.callout.bold()).foregroundStyle(.tint) + } + } + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.headline) + Text(body).font(.callout).foregroundStyle(.secondary) + controls() + } + } + .padding(16) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 12)) + } +} + +/// A safe, on-rails first-run demo: a local sample with planted mistakes that +/// the REAL grammar engine fixes in one click. It operates on a plain string +/// (never a live Accessibility element), so it's completely risk-free — the +/// "guaranteed visible win" before GrammarGem asks for anything. +private struct DemoStep: View { + @State private var text = "i has wrote alot of mistakes here, and it dont look profesional" + @State private var issues: [Suggestion] = [] + @State private var cleaned = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(headline) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(cleaned ? GG.emerald : .primary) + Text(highlighted) + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + .animation(.easeInOut(duration: 0.35), value: text) + Button { + let fixed = AppState.shared.grammar.correct(text) + withAnimation(.easeOut(duration: 0.4)) { + text = fixed + issues = [] + cleaned = true + } + } label: { + Label(cleaned ? "All clean" : "Clean it up", + systemImage: cleaned ? "checkmark.circle.fill" : "wand.and.stars") + } + .buttonStyle(.borderedProminent) + .disabled(cleaned || issues.isEmpty) + } + .onAppear { + if issues.isEmpty && !cleaned { issues = AppState.shared.grammar.check(text) } + } + } + + private var headline: String { + if cleaned { return "Clean ✨ — GrammarGem does this in every app you type." } + if issues.isEmpty { return "Checking…" } + return "We spotted \(issues.count) thing\(issues.count == 1 ? "" : "s") to tidy up — one click fixes them." + } + + private var highlighted: AttributedString { + let ns = text as NSString + var result = AttributedString() + var cursor = 0 + for s in issues.sorted(by: { $0.location < $1.location }) { + guard s.location >= cursor, s.length > 0, s.location + s.length <= ns.length else { continue } + if s.location > cursor { + result += AttributedString(ns.substring(with: NSRange(location: cursor, length: s.location - cursor))) + } + var run = AttributedString(ns.substring(with: s.range)) + let tint: Color = s.kind == .spelling ? .red : .orange + run.backgroundColor = tint.opacity(0.18) + run.foregroundColor = tint + run.underlineStyle = .single + result += run + cursor = s.location + s.length + } + if cursor < ns.length { + result += AttributedString(ns.substring(with: NSRange(location: cursor, length: ns.length - cursor))) + } + return result + } +} + +/// Real model download progress + controls, shared by onboarding and settings. +struct ModelDownloadControls: View { + @ObservedObject private var model: ModelManager + + init() { + _model = ObservedObject(wrappedValue: AppState.shared.model) + } + + var body: some View { + switch model.state { + case .notDownloaded: + VStack(alignment: .leading, spacing: 4) { + Button("Download model now") { model.startDownload() } + Text("Installs automatically in the background on first launch (about 1–2 GB from Hugging Face). One time, then fully offline.") + .font(.caption).foregroundStyle(.secondary) + } + case .downloading(let progress): + VStack(alignment: .leading, spacing: 6) { + ProgressView(value: progress) { + Text("Downloading… \(Int(progress * 100))%") + } + .frame(maxWidth: 320) + if !model.statusText.isEmpty { + Text(model.statusText).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Button("Cancel") { model.cancel() }.controlSize(.small) + } + case .ready: + Label("Model ready", systemImage: "checkmark.seal.fill").foregroundStyle(.green) + case .failed(let message): + VStack(alignment: .leading, spacing: 4) { + Label("Download failed", systemImage: "exclamationmark.triangle").foregroundStyle(.orange) + Text(message).font(.caption).foregroundStyle(.secondary).lineLimit(2) + Button("Retry") { model.startDownload() } + } + } + } +} diff --git a/Sources/GrammaGem/App/SettingsView.swift b/Sources/GrammarGem/App/SettingsView.swift similarity index 97% rename from Sources/GrammaGem/App/SettingsView.swift rename to Sources/GrammarGem/App/SettingsView.swift index 32981b9..b293065 100644 --- a/Sources/GrammaGem/App/SettingsView.swift +++ b/Sources/GrammarGem/App/SettingsView.swift @@ -25,7 +25,7 @@ private struct GeneralSettings: View { Form { Section("Shortcuts") { LabeledContent("Fix selection", value: "⌘;") - LabeledContent("Ask GrammaGem", value: "⌘'") + LabeledContent("Ask GrammarGem", value: "⌘'") Text("Custom shortcuts coming via the KeyboardShortcuts recorder (see README).") .font(.caption).foregroundStyle(.secondary) } @@ -147,7 +147,7 @@ private struct DictionarySettings: View { var body: some View { VStack(alignment: .leading, spacing: 12) { HStack { - TextField("Add a word GrammaGem should never correct", text: $newWord) + TextField("Add a word GrammarGem should never correct", text: $newWord) .textFieldStyle(.roundedBorder) .onSubmit(add) Button("Add", action: add).disabled(newWord.trimmingCharacters(in: .whitespaces).isEmpty) diff --git a/Sources/GrammaGem/Core/AppConfig.swift b/Sources/GrammarGem/Core/AppConfig.swift similarity index 65% rename from Sources/GrammaGem/Core/AppConfig.swift rename to Sources/GrammarGem/Core/AppConfig.swift index a1016a2..9f0f998 100644 --- a/Sources/GrammaGem/Core/AppConfig.swift +++ b/Sources/GrammarGem/Core/AppConfig.swift @@ -1,15 +1,15 @@ import Foundation /// Compile-time configuration. The Lemon Squeezy identifiers are hard-coded so -/// the licensing layer can *hard-verify* that a key belongs to GrammaGem (and to +/// the licensing layer can *hard-verify* that a key belongs to GrammarGem (and to /// the claimed tier) rather than to some other Lemon Squeezy product. /// TODO(real-integration): replace every `TODO_*` value with your real IDs. enum AppConfig { - static let appName = "GrammaGem" - static let bundleIdentifier = "com.foundergem.grammagem" // TODO(real-integration) - static let supportEmail = "support@grammagem.app" // TODO(real-integration) - static let modelPortalURL = URL(string: "https://grammagem.app/license")! // TODO - static let websiteURL = URL(string: "https://grammagem.app")! + static let appName = "GrammarGem" + static let bundleIdentifier = "com.foundergem.grammargem" // TODO(real-integration) + static let supportEmail = "support@grammargem.com" // TODO(real-integration) + static let modelPortalURL = URL(string: "https://www.grammargem.com/license")! // TODO + static let websiteURL = URL(string: "https://www.grammargem.com")! static let appVersion = "0.1.0" // Legal entity (shown in About / legal). @@ -37,6 +37,23 @@ enum AppConfig { static let huggingFaceBase = "https://huggingface.co" } + /// The cross-platform Layer-2 backend: a locally-running Ollama server. Used + /// when MLX isn't available (Windows/Linux) or when the user opts in. Inference + /// stays on-device — Ollama runs the model on the user's own machine. + enum Ollama { + static let baseURL = URL(string: "http://127.0.0.1:11434")! + static let defaultModel = "qwen2.5:3b" // matches the MLX default model family + } + + /// Loopback engine server for the browser extension (off by default; see + /// `LocalEngineServer`). Exposes Harper + the local LLM to the extension over + /// 127.0.0.1 with a per-install token — no cloud round-trip. + enum LocalServer { + static let port: UInt16 = 47800 + static let tokenKey = "GrammarGem.localServerToken" + static let enableKey = "GrammarGem.enableLocalServer" + } + /// Free-tier limits + license validation cadence (see spec §5/§6). enum Limits { static let freeDailyAIActions = 10 diff --git a/Sources/GrammaGem/Core/Logger.swift b/Sources/GrammarGem/Core/Logger.swift similarity index 100% rename from Sources/GrammaGem/Core/Logger.swift rename to Sources/GrammarGem/Core/Logger.swift diff --git a/Sources/GrammaGem/Core/Models.swift b/Sources/GrammarGem/Core/Models.swift similarity index 74% rename from Sources/GrammaGem/Core/Models.swift rename to Sources/GrammarGem/Core/Models.swift index d927e32..2676d8f 100644 --- a/Sources/GrammaGem/Core/Models.swift +++ b/Sources/GrammarGem/Core/Models.swift @@ -42,6 +42,42 @@ enum GrammarKind: String, Codable { case phrasing case repetition case capitalization + + /// Severity weight — drives card ordering and the writing-score penalty. + /// Objective errors (spelling, grammar) weigh most. + var severity: Int { + switch self { + case .spelling, .grammar: return 3 + case .punctuation, .capitalization: return 2 + case .repetition, .phrasing: return 1 + } + } + + /// User-facing grouping shown as filter chips in the Live Check panel. + var bucket: IssueBucket { + switch self { + case .spelling, .grammar, .punctuation: return .correctness + case .phrasing: return .clarity + case .repetition, .capitalization: return .polish + } + } +} + +/// Our own named issue groups (deliberately NOT Grammarly's exact buckets/words): +/// objective errors, clarity rewrites, and stylistic polish. +enum IssueBucket: String, CaseIterable, Identifiable { + case correctness + case clarity + case polish + + var id: String { rawValue } + var title: String { + switch self { + case .correctness: return "Correctness" + case .clarity: return "Clarity" + case .polish: return "Polish" + } + } } /// A single grammar suggestion over a span of the input text. diff --git a/Sources/GrammaGem/Entitlements/Entitlements.swift b/Sources/GrammarGem/Entitlements/Entitlements.swift similarity index 100% rename from Sources/GrammaGem/Entitlements/Entitlements.swift rename to Sources/GrammarGem/Entitlements/Entitlements.swift diff --git a/Sources/GrammaGem/Entitlements/FeatureGate.swift b/Sources/GrammarGem/Entitlements/FeatureGate.swift similarity index 98% rename from Sources/GrammaGem/Entitlements/FeatureGate.swift rename to Sources/GrammarGem/Entitlements/FeatureGate.swift index 981a02c..13e56ef 100644 --- a/Sources/GrammaGem/Entitlements/FeatureGate.swift +++ b/Sources/GrammarGem/Entitlements/FeatureGate.swift @@ -72,7 +72,7 @@ final class FeatureGate: ObservableObject { /// Free-tier AI action counter. Persists `{date, count}` in UserDefaults and /// resets at local midnight (compares the stored local date string to today's). final class DailyCounter { - private let key = "GrammaGem.aiActions.daily" + private let key = "GrammarGem.aiActions.daily" private let defaults = UserDefaults.standard private static let formatter: DateFormatter = { diff --git a/Sources/GrammaGem/Grammar/GrammarEngine.swift b/Sources/GrammarGem/Grammar/GrammarEngine.swift similarity index 76% rename from Sources/GrammaGem/Grammar/GrammarEngine.swift rename to Sources/GrammarGem/Grammar/GrammarEngine.swift index 38282a7..d559fc3 100644 --- a/Sources/GrammaGem/Grammar/GrammarEngine.swift +++ b/Sources/GrammarGem/Grammar/GrammarEngine.swift @@ -19,9 +19,15 @@ protocol GrammarEngine: AnyObject { extension GrammarEngine { /// Default correction: apply suggestions right-to-left so offsets stay valid. + /// + /// No-op flags (where the replacement equals the original — e.g. a grammar + /// issue Harper can describe but not auto-fix) are skipped, so "Fix all" / + /// the in-place hotkey only ever apply real, text-changing corrections and + /// never silently rewrite a span to itself. func correct(_ text: String) -> String { let ns = NSMutableString(string: text) for s in check(text).sorted(by: { $0.location > $1.location }) { + guard s.replacement != s.original else { continue } guard s.location + s.length <= ns.length else { continue } ns.replaceCharacters(in: s.range, with: s.replacement) } diff --git a/Sources/GrammarGem/Grammar/HarperEngine.swift b/Sources/GrammarGem/Grammar/HarperEngine.swift new file mode 100644 index 0000000..139060e --- /dev/null +++ b/Sources/GrammarGem/Grammar/HarperEngine.swift @@ -0,0 +1,83 @@ +import Foundation +import CHarper + +/// Layer 1 — the real **Harper** grammar core (Apache-2.0), embedded via a Rust +/// C-FFI static lib (`harper-ffi/`, harper-core 2.5.0). Fully offline, +/// deterministic, and fast — real subject-verb agreement, verb forms, +/// punctuation, repetition, capitalization and spelling, not just a spell check. +/// +/// `harper_check` returns a JSON array of spans with **char (Unicode scalar)** +/// offsets; we map those onto UTF-16 `NSRange` offsets so they compose with the +/// rest of the app (AX ranges, `NSMutableString`). +final class HarperEngine: GrammarEngine { + var ignoreList: Set = [] + + func check(_ text: String) -> [Suggestion] { + guard !text.isEmpty else { return [] } + + // Call into Rust. The returned C string is heap-allocated by Harper and + // must be freed with harper_free (it outlives the withCString buffer). + guard let raw = text.withCString({ harper_check($0) }) else { return [] } + defer { harper_free(raw) } + + let json = String(cString: raw) + guard let data = json.data(using: .utf8), + let lints = try? JSONDecoder().decode([HarperLint].self, from: data) + else { return [] } + + // Harper can emit several lints over the SAME span (e.g. two phrasings of + // "checkin" -> "check in"). Keep the first (highest-priority) per span so + // the right-to-left bulk apply in `correct()` never double-writes a range. + var kept: [Suggestion] = [] + for lint in lints { + guard lint.len > 0 || !lint.replacement.isEmpty else { continue } + guard let range = Self.utf16Range(scalarStart: lint.start, scalarLen: lint.len, in: text) + else { continue } + let original = (text as NSString).substring(with: range) + if !original.isEmpty, ignoreList.contains(original.lowercased()) { continue } + if kept.contains(where: { NSIntersectionRange($0.range, range).length > 0 }) { continue } + kept.append(Suggestion( + location: range.location, length: range.length, + original: original, replacement: lint.replacement, + kind: GrammarKind(harperKind: lint.kind), message: lint.message)) + } + return kept.sorted { $0.location < $1.location } + } + + // MARK: - FFI decoding + + private struct HarperLint: Decodable { + let start: Int + let len: Int + let replacement: String + let message: String + let kind: String + } + + /// Map a Harper char-span (Unicode scalar offsets, end-exclusive) onto a + /// UTF-16 `NSRange`. Scalar boundaries are always UTF-16 boundaries, so the + /// conversion is exact. + static func utf16Range(scalarStart: Int, scalarLen: Int, in text: String) -> NSRange? { + let scalars = text.unicodeScalars + guard let s = scalars.index(scalars.startIndex, offsetBy: scalarStart, limitedBy: scalars.endIndex), + let e = scalars.index(s, offsetBy: scalarLen, limitedBy: scalars.endIndex) + else { return nil } + let location = text.utf16.distance(from: text.utf16.startIndex, to: s) + let length = text.utf16.distance(from: s, to: e) + return NSRange(location: location, length: length) + } +} + +extension GrammarKind { + /// Map Harper's `kind` string (see harper.h) onto our category enum. + init(harperKind kind: String) { + switch kind { + case "spelling": self = .spelling + case "punctuation": self = .punctuation + case "repetition": self = .repetition + case "capitalization": self = .capitalization + case "phrasing": self = .phrasing + default: self = .grammar + } + } +} diff --git a/Sources/GrammaGem/Grammar/PersonalDictionary.swift b/Sources/GrammarGem/Grammar/PersonalDictionary.swift similarity index 93% rename from Sources/GrammaGem/Grammar/PersonalDictionary.swift rename to Sources/GrammarGem/Grammar/PersonalDictionary.swift index 97764ac..29afb66 100644 --- a/Sources/GrammaGem/Grammar/PersonalDictionary.swift +++ b/Sources/GrammarGem/Grammar/PersonalDictionary.swift @@ -1,13 +1,13 @@ import Foundation -/// The user's personal dictionary — names, brand terms, jargon GrammaGem should +/// The user's personal dictionary — names, brand terms, jargon GrammarGem should /// never "correct". Feeds Harper's ignore-list and the LLM context. Capped at 25 /// entries on free, unlimited on paid (enforced by `FeatureGate`, not here). @MainActor final class PersonalDictionary: ObservableObject { @Published private(set) var entries: [String] - private let key = "GrammaGem.personalDictionary" + private let key = "GrammarGem.personalDictionary" private let defaults = UserDefaults.standard init() { diff --git a/Sources/GrammarGem/Grammar/SystemGrammarEngine.swift b/Sources/GrammarGem/Grammar/SystemGrammarEngine.swift new file mode 100644 index 0000000..7bd85b5 --- /dev/null +++ b/Sources/GrammarGem/Grammar/SystemGrammarEngine.swift @@ -0,0 +1,90 @@ +import Foundation +import AppKit + +/// Layer 1 orchestrator. The real **Harper** core is the primary engine +/// (grammar, punctuation, repetition, capitalization, spelling); macOS's +/// `NSSpellChecker` is layered on top purely for *extra spelling coverage* from +/// its large system dictionary. +/// +/// Two correctness rules make the combination trustworthy: +/// 1. **Harper wins on overlap** — a system spelling guess is dropped if Harper +/// already flagged the same span (Harper's fixes carry correct casing + a +/// real explanation). +/// 2. **Case-preserving** — a system spelling replacement is matched to the +/// original token's casing, so a mid-sentence word is never spuriously +/// capitalized (the historical `problemm` -> `Problem` bug). Genuine +/// sentence-start capitalization is surfaced separately by Harper. +final class SystemGrammarEngine: GrammarEngine { + var ignoreList: Set = [] { + didSet { harper.ignoreList = ignoreList } + } + + private let checker = NSSpellChecker.shared + private let tag = NSSpellChecker.uniqueSpellDocumentTag() + private let harper = HarperEngine() + + func check(_ text: String) -> [Suggestion] { + guard text.count >= 2 else { return [] } + var out = harper.check(text) + + // Supplement with NSSpellChecker spelling only (Harper owns grammar), + // de-duped against Harper spans and case-matched to the original token. + let spelling = onMain { self.spellingSupplement(text) } + for s in spelling where !out.contains(where: { overlaps($0.range, s.range) }) { + out.append(s) + } + return out.sorted { $0.location < $1.location } + } + + private func spellingSupplement(_ text: String) -> [Suggestion] { + let ns = text as NSString + let full = NSRange(location: 0, length: ns.length) + let language = checker.language() + let results = checker.check( + text, range: full, types: NSTextCheckingResult.CheckingType.spelling.rawValue, + options: nil, inSpellDocumentWithTag: tag, orthography: nil, wordCount: nil) + + var out: [Suggestion] = [] + for r in results where r.resultType == .spelling { + let word = ns.substring(with: r.range) + if ignoreList.contains(word.lowercased()) { continue } + guard let raw = checker.correction( + forWordRange: r.range, in: text, language: language, inSpellDocumentWithTag: tag) + ?? checker.guesses( + forWordRange: r.range, in: text, language: language, + inSpellDocumentWithTag: tag)?.first + else { continue } + let fix = Self.matchingCase(of: word, in: raw) + guard fix != word else { continue } // never emit a no-op + out.append(Suggestion( + location: r.range.location, length: r.range.length, + original: word, replacement: fix, + kind: .spelling, message: "Possible spelling mistake")) + } + return out + } + + /// Re-case `replacement` to follow `original`'s casing. + static func matchingCase(of original: String, in replacement: String) -> String { + guard let o = original.first, let r = replacement.first else { return replacement } + // ALL-CAPS original (with at least one cased letter) -> upper the whole thing. + if original.count > 1, original == original.uppercased(), original != original.lowercased() { + return replacement.uppercased() + } + if o.isLowercase, r.isUppercase { + return r.lowercased() + replacement.dropFirst() + } + if o.isUppercase, r.isLowercase { + return r.uppercased() + replacement.dropFirst() + } + return replacement + } + + private func overlaps(_ a: NSRange, _ b: NSRange) -> Bool { + NSIntersectionRange(a, b).length > 0 + } + + private func onMain(_ work: @escaping () -> T) -> T { + Thread.isMainThread ? work() : DispatchQueue.main.sync(execute: work) + } +} diff --git a/Sources/GrammarGem/Grammar/WritingScore.swift b/Sources/GrammarGem/Grammar/WritingScore.swift new file mode 100644 index 0000000..dd1988a --- /dev/null +++ b/Sources/GrammarGem/Grammar/WritingScore.swift @@ -0,0 +1,20 @@ +import Foundation + +/// A deterministic, on-device "cleanliness" score (0–100) for a chunk of text. +/// +/// Honest by construction: it's defined relative to *this* text — weighted issues +/// per 100 words — never a hidden cloud cohort, and grammar/spelling are always +/// fully resolvable, so a clean document genuinely reaches 100. (Contrast with +/// Grammarly's percentile-vs-other-users score, which needs telemetry + an +/// account; see docs/grammarly-value-plan.md §5.) +enum WritingScore { + /// - Parameters: + /// - weightedIssues: sum of `GrammarKind.severity` across outstanding issues. + /// - words: total words in the scanned text. + static func score(weightedIssues: Int, words: Int) -> Int { + guard words > 0 else { return 100 } + let perHundredWords = Double(weightedIssues) / Double(words) * 100.0 + // Each weighted issue per 100 words costs ~4 points; clamp to 0…100. + return max(0, min(100, Int((100.0 - perHundredWords * 4.0).rounded()))) + } +} diff --git a/Sources/GrammaGem/Licensing/DeviceFingerprint.swift b/Sources/GrammarGem/Licensing/DeviceFingerprint.swift similarity index 97% rename from Sources/GrammaGem/Licensing/DeviceFingerprint.swift rename to Sources/GrammarGem/Licensing/DeviceFingerprint.swift index be5ae2b..b17ee51 100644 --- a/Sources/GrammaGem/Licensing/DeviceFingerprint.swift +++ b/Sources/GrammarGem/Licensing/DeviceFingerprint.swift @@ -38,7 +38,7 @@ enum DeviceFingerprint { } private static func fallbackID() -> String { - let key = "GrammaGem.fallbackDeviceID" + let key = "GrammarGem.fallbackDeviceID" if let existing = UserDefaults.standard.string(forKey: key) { return existing } let id = UUID().uuidString UserDefaults.standard.set(id, forKey: key) diff --git a/Sources/GrammaGem/Licensing/Keychain.swift b/Sources/GrammarGem/Licensing/Keychain.swift similarity index 100% rename from Sources/GrammaGem/Licensing/Keychain.swift rename to Sources/GrammarGem/Licensing/Keychain.swift diff --git a/Sources/GrammaGem/Licensing/LemonSqueezyClient.swift b/Sources/GrammarGem/Licensing/LemonSqueezyClient.swift similarity index 86% rename from Sources/GrammaGem/Licensing/LemonSqueezyClient.swift rename to Sources/GrammarGem/Licensing/LemonSqueezyClient.swift index 5b9a6b4..58e5399 100644 --- a/Sources/GrammaGem/Licensing/LemonSqueezyClient.swift +++ b/Sources/GrammarGem/Licensing/LemonSqueezyClient.swift @@ -2,7 +2,7 @@ import Foundation /// Talks to the **Lemon Squeezy License API** directly from the app — no server /// of ours, no database, no per-user cost (spec §6). Exposes activate / validate -/// / deactivate and hard-verifies that a key belongs to GrammaGem + the claimed tier. +/// / deactivate and hard-verifies that a key belongs to GrammarGem + the claimed tier. final class LemonSqueezyClient { private let session: URLSession private let base = AppConfig.LemonSqueezy.apiBase @@ -54,20 +54,28 @@ final class LemonSqueezyClient { // MARK: - Hard verification (spec §6) /// Confirm the key is for OUR store/product, then resolve which tier its - /// variant corresponds to. Prevents a key from another LS product unlocking GrammaGem. + /// variant corresponds to. Prevents a key from another LS product unlocking GrammarGem. private func verifyAndResolveTier(meta: LSMeta?, licenseKey: LSLicenseKey?) throws -> Tier { guard let meta else { throw LicenseError.wrongProduct } - // Store + product check. If AppConfig still holds placeholder (non-numeric) - // IDs, we log and skip the check so the stub flow runs — TODO(real-integration): - // once real numeric IDs are set, this becomes a hard gate. + // Store + product check. With real numeric IDs configured this is a hard + // gate that prevents a key from another Lemon Squeezy product unlocking + // GrammarGem. If AppConfig still holds placeholder (non-numeric) IDs the + // gate can't run — allow the bypass ONLY in DEBUG so local stub flows work; + // in a release build FAIL CLOSED so the "any key unlocks paid" bypass can + // never ship. TODO(real-integration): set real IDs in AppConfig.LemonSqueezy. if let expectedStore = Int(AppConfig.LemonSqueezy.expectedStoreID), let expectedProduct = Int(AppConfig.LemonSqueezy.expectedProductID) { guard meta.store_id == expectedStore, meta.product_id == expectedProduct else { throw LicenseError.wrongProduct } } else { - Log.licensing.warning("Skipping store/product hard-verify — placeholder IDs in AppConfig.") + #if DEBUG + Log.licensing.warning("Skipping store/product hard-verify — placeholder IDs in AppConfig (DEBUG only).") + #else + Log.licensing.error("Refusing to verify license: placeholder Lemon Squeezy IDs in a release build.") + throw LicenseError.wrongProduct + #endif } // Resolve tier from the variant id (reverse-lookup the configured map). diff --git a/Sources/GrammaGem/Licensing/LicenseManager.swift b/Sources/GrammarGem/Licensing/LicenseManager.swift similarity index 100% rename from Sources/GrammaGem/Licensing/LicenseManager.swift rename to Sources/GrammarGem/Licensing/LicenseManager.swift diff --git a/Sources/GrammaGem/Licensing/LicenseModels.swift b/Sources/GrammarGem/Licensing/LicenseModels.swift similarity index 99% rename from Sources/GrammaGem/Licensing/LicenseModels.swift rename to Sources/GrammarGem/Licensing/LicenseModels.swift index a9196a5..140a3e2 100644 --- a/Sources/GrammaGem/Licensing/LicenseModels.swift +++ b/Sources/GrammarGem/Licensing/LicenseModels.swift @@ -62,7 +62,7 @@ struct LSLicenseKey: Codable { } /// `meta` carries the store/product/variant identity we *hard-verify* against -/// AppConfig so a key from another Lemon Squeezy product can't unlock GrammaGem. +/// AppConfig so a key from another Lemon Squeezy product can't unlock GrammarGem. struct LSMeta: Codable { let store_id: Int? let product_id: Int? diff --git a/Sources/GrammaGem/Preferences/Preferences.swift b/Sources/GrammarGem/Preferences/Preferences.swift similarity index 100% rename from Sources/GrammaGem/Preferences/Preferences.swift rename to Sources/GrammarGem/Preferences/Preferences.swift diff --git a/Sources/GrammarGem/Stats/UsageStats.swift b/Sources/GrammarGem/Stats/UsageStats.swift new file mode 100644 index 0000000..40d5db0 --- /dev/null +++ b/Sources/GrammarGem/Stats/UsageStats.swift @@ -0,0 +1,154 @@ +import Foundation + +/// Privacy-friendly, fully-local usage insights. Counts *how often* features are +/// used — never *what* was written. Stored only on this Mac. +@MainActor +final class UsageStats: ObservableObject { + struct Snapshot: Codable, Equatable { + var totalCorrections = 0 + var totalAIActions = 0 + var totalWords = 0 + var activeDays = 0 // distinct days with ≥1 action (lifetime) + var firstUseDay: String? // "yyyy-MM-dd" of first ever action + var perDay: [String: Int] = [:] // "yyyy-MM-dd" -> actions that day + } + + /// What a Grammarly-style subscription runs per year (USD) — used only for the + /// honest, local "subscription you didn't pay" figure. GrammarGem is one-time. + static let comparableAnnualSubscriptionUSD = 144.0 + + @Published private(set) var data: Snapshot + + private let key = "GrammarGem.usageStats" + private let d = UserDefaults.standard + + private static let fmt: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = .current + return f + }() + + init() { + if let raw = d.data(forKey: key), + let decoded = try? JSONDecoder().decode(Snapshot.self, from: raw) { + data = decoded + } else { + data = Snapshot() + } + } + + func recordCorrection(words: Int) { + data.totalCorrections += 1 + data.totalWords += max(0, words) + bumpToday() + persist() + } + + func recordAIAction(words: Int) { + data.totalAIActions += 1 + data.totalWords += max(0, words) + bumpToday() + persist() + } + + /// A playful, clearly-estimated "time saved" figure (local only). + var estimatedMinutesSaved: Int { + (data.totalCorrections * 8 + data.totalAIActions * 45) / 60 + } + + // MARK: - Value loop (streak / milestones / subscription-you-didn't-pay) + + /// Consecutive days, ending today or yesterday, with at least one action. + var currentStreak: Int { Self.streak(perDay: data.perDay, asOf: Date()) } + + /// The Grammarly subscription you DIDN'T pay: their annual price prorated over + /// how long you've owned GrammarGem. Honest, local, illustrative — and it only + /// grows, which is the point of a one-time purchase (see the value plan §5). + var subscriptionAvoidedUSD: Int { + Self.subscriptionAvoidedUSD( + firstUseDay: data.firstUseDay, asOf: Date(), + annualPrice: Self.comparableAnnualSubscriptionUSD) + } + + /// The next correction milestone and progress toward it (for a nudge/badge). + var nextMilestone: (target: Int, label: String, progress: Double) { + Self.nextMilestone(corrections: data.totalCorrections) + } + + // MARK: - Pure helpers (unit-testable; no UserDefaults / wall clock) + + static func streak(perDay: [String: Int], asOf now: Date) -> Int { + let cal = Calendar.current + func used(_ day: Date) -> Bool { (perDay[fmt.string(from: day)] ?? 0) > 0 } + var day = now + if !used(day) { + // Streak is still alive if used yesterday but not yet today. + day = cal.date(byAdding: .day, value: -1, to: day) ?? day + if !used(day) { return 0 } + } + var streak = 0 + while used(day) { + streak += 1 + day = cal.date(byAdding: .day, value: -1, to: day) ?? day + } + return streak + } + + static func subscriptionAvoidedUSD(firstUseDay: String?, asOf now: Date, annualPrice: Double) -> Int { + guard let first = firstUseDay, let firstDate = fmt.date(from: first) else { return 0 } + let days = max(1, Calendar.current.dateComponents([.day], from: firstDate, to: now).day ?? 0) + return Int((Double(days) / 365.0 * annualPrice).rounded()) + } + + static func nextMilestone(corrections: Int) -> (target: Int, label: String, progress: Double) { + let targets = [100, 500, 1_000, 5_000, 10_000, 50_000, 100_000] + let target = targets.first { $0 > corrections } ?? (((corrections / 100_000) + 1) * 100_000) + let previous = targets.last { $0 <= corrections } ?? 0 + let span = max(1, target - previous) + let progress = min(1.0, Double(corrections - previous) / Double(span)) + let label = target >= 1_000 ? "\(target / 1_000)k corrections" : "\(target) corrections" + return (target, label, progress) + } + + /// Counts for the last 7 calendar days, oldest → newest, for a small chart. + func last7Days() -> [(label: String, count: Int)] { + let cal = Calendar.current + let today = Date() + return (0..<7).reversed().map { offset in + let day = cal.date(byAdding: .day, value: -offset, to: today) ?? today + let key = Self.fmt.string(from: day) + let short = day.formatted(.dateTime.weekday(.narrow)) + return (label: short, count: data.perDay[key] ?? 0) + } + } + + func reset() { + data = Snapshot() + persist() + } + + private func bumpToday() { + let k = Self.fmt.string(from: Date()) + if data.perDay[k] == nil { // first action on a brand-new day + data.activeDays += 1 + if data.firstUseDay == nil { data.firstUseDay = k } + } + data.perDay[k, default: 0] += 1 + // Keep only ~60 days of history. + if data.perDay.count > 70 { + let keep = Set((0..<60).compactMap { off -> String? in + guard let day = Calendar.current.date(byAdding: .day, value: -off, to: Date()) else { return nil } + return Self.fmt.string(from: day) + }) + data.perDay = data.perDay.filter { keep.contains($0.key) } + } + } + + private func persist() { + if let raw = try? JSONEncoder().encode(data) { + d.set(raw, forKey: key) + } + } +} diff --git a/Sources/GrammarGem/System/AXSupport.swift b/Sources/GrammarGem/System/AXSupport.swift new file mode 100644 index 0000000..7858204 --- /dev/null +++ b/Sources/GrammarGem/System/AXSupport.swift @@ -0,0 +1,76 @@ +import Foundation +import AppKit +import ApplicationServices + +/// Safe Accessibility helpers shared across the System layer. +/// +/// `AXUIElementCopyAttributeValue` returning `.success` only guarantees a +/// non-null `CFTypeRef` — NOT that it is an `AXUIElement`. A misbehaving AX +/// server (Electron/Java/custom) can return a different CF type, and a forced +/// `as!` cast then traps and crashes the whole app. These helpers cast safely. +enum AX { + /// Safely bridge a CFTypeRef to AXUIElement, returning nil on type mismatch. + static func element(_ ref: CFTypeRef?) -> AXUIElement? { + guard let ref, CFGetTypeID(ref) == AXUIElementGetTypeID() else { return nil } + return (ref as! AXUIElement) // safe: type id verified above + } + + /// Copy an attribute as an `AXUIElement` (nil on failure/type mismatch). + static func copyElement(_ element: AXUIElement, _ attribute: String) -> AXUIElement? { + var ref: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success else { + return nil + } + return AX.element(ref) + } + + /// Copy an attribute as a `String` (nil on failure/wrong type). + static func copyString(_ element: AXUIElement, _ attribute: String) -> String? { + var ref: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success else { + return nil + } + return ref as? String + } + + /// Bound each cross-process AX message so a wedged target app can't hang us. + static func setMessagingTimeout(_ element: AXUIElement, _ seconds: Float) { + AXUIElementSetMessagingTimeout(element, seconds) + } + + /// Whether an element is a secure text field (a password input). GrammarGem + /// must NEVER read or rewrite these — so a user who forgot to exclude a + /// password manager or login form can't have a decrypted password captured, + /// sent through the grammar/AI engines, and written back. This is enforced in + /// code rather than relying on the user's exclusion blocklist. + static func isSecureTextField(_ element: AXUIElement) -> Bool { + // AXSecureTextField is the subrole AppKit/most Electron inputs report for + // password fields. String-compared (the constant isn't always imported). + copyString(element, kAXSubroleAttribute) == "AXSecureTextField" + } + + /// The currently-focused UI element across the system — robust to the + /// system-wide element failing. + /// + /// On recent macOS, `AXUIElementCreateSystemWide()` returns + /// `kAXErrorCannotComplete` (-25204) for *every* query made by a background / + /// accessory process (which is exactly what a menu-bar app like GrammarGem is). + /// When that happens the system-wide focused-element read yields nil and the + /// app "sees" no text or text fields at all. Resolving the focused element + /// through the **frontmost application's** own AX element works reliably in + /// that context, so we prefer the system-wide read (one IPC hop, correct on + /// OSes where it works) and transparently fall back to the per-app read. + static func focusedElement() -> AXUIElement? { + let systemWide = AXUIElementCreateSystemWide() + setMessagingTimeout(systemWide, 0.5) + if let element = copyElement(systemWide, kAXFocusedUIElementAttribute as String) { + return element + } + guard let pid = NSWorkspace.shared.frontmostApplication?.processIdentifier else { + return nil + } + let axApp = AXUIElementCreateApplication(pid) + setMessagingTimeout(axApp, 0.5) + return copyElement(axApp, kAXFocusedUIElementAttribute as String) + } +} diff --git a/Sources/GrammaGem/System/AppDetector.swift b/Sources/GrammarGem/System/AppDetector.swift similarity index 100% rename from Sources/GrammaGem/System/AppDetector.swift rename to Sources/GrammarGem/System/AppDetector.swift diff --git a/Sources/GrammaGem/System/Exclusions.swift b/Sources/GrammarGem/System/Exclusions.swift similarity index 92% rename from Sources/GrammaGem/System/Exclusions.swift rename to Sources/GrammarGem/System/Exclusions.swift index 4587ed7..84ac71f 100644 --- a/Sources/GrammaGem/System/Exclusions.swift +++ b/Sources/GrammarGem/System/Exclusions.swift @@ -1,7 +1,7 @@ import Foundation import AppKit -/// The "page blocker": apps and domains where GrammaGem should stay completely +/// The "page blocker": apps and domains where GrammarGem should stay completely /// out of the way — password managers, banking sites, anything sensitive. /// When the frontmost context is excluded, live monitoring and the hotkeys are /// suppressed. @@ -10,8 +10,8 @@ final class Exclusions: ObservableObject { @Published private(set) var blockedApps: [String] // bundle identifiers @Published private(set) var blockedDomains: [String] // hostnames, e.g. "chase.com" - private let appsKey = "GrammaGem.blockedApps" - private let domainsKey = "GrammaGem.blockedDomains" + private let appsKey = "GrammarGem.blockedApps" + private let domainsKey = "GrammarGem.blockedDomains" private let d = UserDefaults.standard /// Common sensitive apps offered as one-tap suggestions in the UI. @@ -31,7 +31,7 @@ final class Exclusions: ObservableObject { /// a browser's URL can't be read). var hasDomainRules: Bool { !blockedDomains.isEmpty } - /// Whether GrammaGem should be suppressed for this frontmost context. + /// Whether GrammarGem should be suppressed for this frontmost context. func isBlocked(bundleID: String?, domain: String?) -> Bool { if let bundleID, blockedApps.contains(bundleID) { return true } if let host = domain?.lowercased(), !host.isEmpty { diff --git a/Sources/GrammaGem/System/HotkeyCombo.swift b/Sources/GrammarGem/System/HotkeyCombo.swift similarity index 100% rename from Sources/GrammaGem/System/HotkeyCombo.swift rename to Sources/GrammarGem/System/HotkeyCombo.swift diff --git a/Sources/GrammaGem/System/HotkeyManager.swift b/Sources/GrammarGem/System/HotkeyManager.swift similarity index 100% rename from Sources/GrammaGem/System/HotkeyManager.swift rename to Sources/GrammarGem/System/HotkeyManager.swift diff --git a/Sources/GrammaGem/System/LiveMonitor.swift b/Sources/GrammarGem/System/LiveMonitor.swift similarity index 73% rename from Sources/GrammaGem/System/LiveMonitor.swift rename to Sources/GrammarGem/System/LiveMonitor.swift index 8d9124c..ad6b65c 100644 --- a/Sources/GrammaGem/System/LiveMonitor.swift +++ b/Sources/GrammarGem/System/LiveMonitor.swift @@ -19,18 +19,40 @@ struct DetectedField: Identifiable { /// Performance & safety: the Accessibility-tree traversal (the part that can hang /// on a busy/wedged app) runs OFF the main thread with a per-message timeout; only /// the bounded spell/grammar pass and publish happen on the main actor. It never -/// scans GrammaGem itself, respects the page blocker, and applies fixes against the +/// scans GrammarGem itself, respects the page blocker, and applies fixes against the /// element's *live* value so it can't overwrite text typed since the last scan. @MainActor final class LiveMonitor: ObservableObject { @Published private(set) var fields: [DetectedField] = [] @Published private(set) var scannedCount = 0 + @Published private(set) var scannedWordCount = 0 @Published private(set) var activeAppName = "" @Published private(set) var running = false /// Total number of issues across all detected text areas. var issueCount: Int { fields.reduce(0) { $0 + $1.suggestions.count } } + /// Outstanding issues grouped into the user-facing buckets (for filter chips). + var bucketCounts: [IssueBucket: Int] { + var counts: [IssueBucket: Int] = [:] + for field in fields { + for s in field.suggestions { counts[s.kind.bucket, default: 0] += 1 } + } + return counts + } + + /// 0–100 on-device cleanliness score for the text currently on screen. + var writingScore: Int { + let weighted = fields.reduce(0) { acc, f in + acc + f.suggestions.reduce(0) { $0 + $1.kind.severity } + } + return WritingScore.score(weightedIssues: weighted, words: scannedWordCount) + } + + /// Content keys of suggestions the user has dismissed; survives re-scans so a + /// dismissed issue doesn't reappear on the next poll. + private var dismissedKeys: Set = [] + private let grammar: GrammarEngine private let detector: AppDetector private let exclusions: Exclusions @@ -88,6 +110,36 @@ final class LiveMonitor: ObservableObject { refreshSoon() } + // MARK: - Dismissing (hide a suggestion without applying it) + + /// Hide one suggestion. A cheap, always-available "no" is what makes saying + /// "yes" (Apply) feel safe. The dismissal is keyed by content so it persists + /// across re-scans of unchanged text. + func dismiss(_ suggestion: Suggestion, in field: DetectedField) { + dismissedKeys.insert(Self.dismissKey(fieldID: field.id, suggestion)) + fields = filterDismissed(fields) + } + + private static func dismissKey(fieldID: String, _ s: Suggestion) -> String { + "\(fieldID)|\(s.location)|\(s.length)|\(s.original)|\(s.replacement)" + } + + private func filterDismissed(_ input: [DetectedField]) -> [DetectedField] { + input.compactMap { f in + let remaining = f.suggestions.filter { + !dismissedKeys.contains(Self.dismissKey(fieldID: f.id, $0)) + } + guard !remaining.isEmpty else { return nil } + return DetectedField(id: f.id, label: f.label, roleName: f.roleName, + text: f.text, suggestions: remaining, + element: f.element, isFocused: f.isFocused) + } + } + + private static func wordCount(_ s: String) -> Int { + s.split { $0.isWhitespace || $0.isNewline }.count + } + /// Correct the element's *current* value (never a stale snapshot), so any /// keystrokes entered since the scan are preserved. private func applyCorrection(to field: DetectedField) -> Int { @@ -127,7 +179,7 @@ final class LiveMonitor: ObservableObject { if scanning && !force { return } // don't pile up scans guard let app = NSWorkspace.shared.frontmostApplication else { clear(); return } - // Never scan GrammaGem's own windows. + // Never scan GrammarGem's own windows. if app.processIdentifier == ownPID { clear(); return } activeAppName = app.localizedName ?? "" @@ -156,6 +208,11 @@ final class LiveMonitor: ObservableObject { guard generation == scanGeneration else { return } // superseded by a newer scan guard !scanned.isEmpty else { clear(); return } + // Total words across ALL scanned fields (clean ones included) so the + // writing score is honest about the whole on-screen text, not just the + // fields that happen to have issues. + scannedWordCount = scanned.reduce(0) { $0 + Self.wordCount($1.text) } + // Change detection includes focus + element identity so tabbing between // same-text fields still updates the focused indicator. var hasher = Hasher() @@ -179,16 +236,17 @@ final class LiveMonitor: ObservableObject { text: f.text, suggestions: suggestions, element: f.element, isFocused: f.isFocused)) } detected.sort { ($0.isFocused ? 0 : 1) < ($1.isFocused ? 0 : 1) } - fields = detected + fields = filterDismissed(detected) } private nonisolated static func systemFocusedElement() -> AXUIElement? { - AX.copyElement(AXUIElementCreateSystemWide(), kAXFocusedUIElementAttribute) + AX.focusedElement() } private func clear() { if !fields.isEmpty { fields = [] } scannedCount = 0 + scannedWordCount = 0 lastHash = 0 } } diff --git a/Sources/GrammarGem/System/LocalEngineServer.swift b/Sources/GrammarGem/System/LocalEngineServer.swift new file mode 100644 index 0000000..eb087f6 --- /dev/null +++ b/Sources/GrammarGem/System/LocalEngineServer.swift @@ -0,0 +1,311 @@ +import Foundation +import Network + +/// A minimal, loopback-only HTTP server that exposes GrammarGem's local engines +/// (Harper grammar + the on-device LLM) to a **browser extension** — the +/// highest-volume surface for a grammar tool — with NO cloud round-trip. Built on +/// Network.framework so it adds no third-party HTTP dependency, keeping the +/// privacy/trust surface minimal. +/// +/// Security posture (deliberate): +/// - Binds to 127.0.0.1 only (never a routable interface), via `requiredLocalEndpoint`. +/// - Every engine endpoint requires a per-install bearer token (`X-GrammarGem-Token`); +/// a web page that doesn't know the token cannot drive the engines. +/// - OFF by default — only starts when the user opts in, so the app ships no +/// listening socket unless explicitly enabled. +/// +/// It reuses the SAME engines as the menu-bar app via injected async handlers, so +/// there is one engine, one set of prompts (`AIPrompts`), and one entitlement gate +/// across the app, the extension, and (later) other backends. +final class LocalEngineServer { + + struct AIRequest { let action: AIAction; let text: String } + + struct WireSuggestion: Encodable { + let location: Int + let length: Int + let original: String + let replacement: String + let kind: String + let message: String + } + + /// Grammar pass: corrected text + suggestion spans for the extension to underline. + typealias GrammarHandler = (String) async -> (corrected: String, suggestions: [WireSuggestion]) + /// AI action outcome (a plain enum so a user-facing message needn't be an `Error`). + enum AIOutcome { case ok(String); case error(String) } + /// AI action: runs the gate + model, returns the result or a user-facing error. + typealias AIHandler = (AIRequest) async -> AIOutcome + /// Streaming AI action: runs the gate + model, invoking `onChunk` per token as it + /// generates, then returns the final outcome (full text, or an error message). + typealias AIStreamHandler = (AIRequest, @escaping (String) -> Void) async -> AIOutcome + + private let port: UInt16 + private let token: String + private let grammarHandler: GrammarHandler + private let aiHandler: AIHandler + private let aiStreamHandler: AIStreamHandler + + private var listener: NWListener? + private let queue = DispatchQueue(label: "com.grammargem.localserver") + + init(port: UInt16, token: String, + grammarHandler: @escaping GrammarHandler, + aiHandler: @escaping AIHandler, + aiStreamHandler: @escaping AIStreamHandler) { + self.port = port + self.token = token + self.grammarHandler = grammarHandler + self.aiHandler = aiHandler + self.aiStreamHandler = aiStreamHandler + } + + /// A stable per-install token, persisted so the extension can be paired once. + static func loadOrCreateToken() -> String { + let d = UserDefaults.standard + if let existing = d.string(forKey: AppConfig.LocalServer.tokenKey), !existing.isEmpty { + return existing + } + let token = UUID().uuidString + UUID().uuidString.replacingOccurrences(of: "-", with: "") + d.set(token, forKey: AppConfig.LocalServer.tokenKey) + return token + } + + func start() { + guard listener == nil else { return } + do { + let params = NWParameters.tcp + guard let nwPort = NWEndpoint.Port(rawValue: port) else { return } + // Loopback-only: refuse to bind to any routable interface. + params.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: nwPort) + let listener = try NWListener(using: params) + listener.newConnectionHandler = { [weak self] conn in self?.accept(conn) } + listener.start(queue: queue) + self.listener = listener + Log.app.info("Local engine server on 127.0.0.1:\(self.port, privacy: .public) (extension bridge)") + } catch { + Log.app.error("Local engine server failed to start: \(error.localizedDescription, privacy: .public)") + } + } + + func stop() { + listener?.cancel() + listener = nil + } + + // MARK: - Connection lifecycle + + private func accept(_ conn: NWConnection) { + conn.start(queue: queue) + receive(conn, buffer: Data()) + } + + private func receive(_ conn: NWConnection, buffer: Data) { + conn.receive(minimumIncompleteLength: 1, maximumLength: 1 << 16) { [weak self] data, _, isComplete, error in + guard let self else { conn.cancel(); return } + var buffer = buffer + if let data { buffer.append(data) } + if let request = HTTPRequest(buffer), request.isComplete { + Task { await self.route(request, on: conn) } + return + } + if isComplete || error != nil { conn.cancel(); return } + self.receive(conn, buffer: buffer) // headers/body not fully arrived yet + } + } + + // MARK: - Routing + + private func route(_ request: HTTPRequest, on conn: NWConnection) async { + // CORS preflight — extensions issue this before a cross-origin POST. + if request.method == "OPTIONS" { + send(conn, status: 204, body: Data()) + return + } + + if request.path == "/health" || request.path == "/v1/health" { + sendJSON(conn, status: 200, HealthResponse(ok: true, app: AppConfig.appName, version: AppConfig.appVersion)) + return + } + + // Token gate for every engine endpoint. + guard request.headers["x-grammargem-token"] == token else { + sendJSON(conn, status: 401, ErrorResponse(error: "unauthorized")) + return + } + + switch (request.method, request.path) { + case ("POST", "/v1/grammar"): + guard let text = decodeText(request.body) else { + sendJSON(conn, status: 400, ErrorResponse(error: "missing 'text'")); return + } + let result = await grammarHandler(text) + sendJSON(conn, status: 200, GrammarResponse(corrected: result.corrected, suggestions: result.suggestions)) + + case ("POST", "/v1/ai"): + guard let req = decodeAI(request.body) else { + sendJSON(conn, status: 400, ErrorResponse(error: "bad request")); return + } + switch await aiHandler(req) { + case .ok(let out): sendJSON(conn, status: 200, AIResponse(result: out)) + case .error(let msg): sendJSON(conn, status: 422, ErrorResponse(error: msg)) + } + + case ("POST", "/v1/ai/stream"): + guard let req = decodeAI(request.body) else { + sendJSON(conn, status: 400, ErrorResponse(error: "bad request")); return + } + // Newline-delimited JSON frames: {"chunk":"…"} per token, then a final + // {"done":true} or {"error":"…"}. The 200 header goes out before tokens, + // so an entitlement/readiness failure surfaces as a closing error frame. + beginStream(conn) + let outcome = await aiStreamHandler(req) { [weak self] chunk in + self?.streamFrame(conn, ["chunk": chunk]) + } + switch outcome { + case .ok: streamFrame(conn, ["done": true]) + case .error(let msg): streamFrame(conn, ["error": msg]) + } + endStream(conn) + + default: + sendJSON(conn, status: 404, ErrorResponse(error: "not found")) + } + } + + // MARK: - Body decoding + + private func decodeText(_ body: Data) -> String? { + struct P: Decodable { let text: String } + guard let p = try? JSONDecoder().decode(P.self, from: body), !p.text.isEmpty else { return nil } + return p.text + } + + private func decodeAI(_ body: Data) -> AIRequest? { + struct P: Decodable { + let action: String + let text: String + let instruction: String? + let language: String? + let mode: String? + let tone: String? + } + guard let p = try? JSONDecoder().decode(P.self, from: body), !p.text.isEmpty else { return nil } + let action: AIAction + switch p.action { + case "rewriteClarity": action = .rewriteClarity + case "rewrite": action = .rewrite + case "ask": + guard let i = p.instruction, !i.trimmingCharacters(in: .whitespaces).isEmpty else { return nil } + action = .ask(i) + case "translate": + guard let l = p.language, !l.isEmpty else { return nil } + action = .translate(language: l) + case "tone": + action = .adjustTone(Tone(rawValue: p.tone ?? "") ?? .professional) + case "mode": + guard let id = p.mode, let m = ModeRegistry.mode(id: id) else { return nil } + action = .applyMode(m) + default: + return nil + } + return AIRequest(action: action, text: p.text) + } + + // MARK: - Responses + + private struct HealthResponse: Encodable { let ok: Bool; let app: String; let version: String } + private struct GrammarResponse: Encodable { let corrected: String; let suggestions: [WireSuggestion] } + private struct AIResponse: Encodable { let result: String } + private struct ErrorResponse: Encodable { let error: String } + + private func sendJSON(_ conn: NWConnection, status: Int, _ value: T) { + let body = (try? JSONEncoder().encode(value)) ?? Data("{}".utf8) + send(conn, status: status, body: body) + } + + // MARK: - Streaming responses (newline-delimited JSON, no Content-Length) + + private func beginStream(_ conn: NWConnection) { + var head = "HTTP/1.1 200 OK\r\n" + head += "Content-Type: application/x-ndjson\r\n" + head += "Cache-Control: no-cache\r\n" + head += "Access-Control-Allow-Origin: *\r\n" + head += "Access-Control-Allow-Headers: Content-Type, X-GrammarGem-Token\r\n" + head += "Connection: close\r\n\r\n" + conn.send(content: Data(head.utf8), completion: .contentProcessed { _ in }) + } + + private func streamFrame(_ conn: NWConnection, _ object: [String: Any]) { + guard var data = try? JSONSerialization.data(withJSONObject: object) else { return } + data.append(0x0A) // newline delimiter + conn.send(content: data, completion: .contentProcessed { _ in }) + } + + private func endStream(_ conn: NWConnection) { + conn.send(content: nil, completion: .contentProcessed { _ in conn.cancel() }) + } + + private func send(_ conn: NWConnection, status: Int, body: Data) { + var head = "HTTP/1.1 \(status) \(Self.reason(status))\r\n" + head += "Content-Type: application/json\r\n" + head += "Content-Length: \(body.count)\r\n" + // The extension calls cross-origin; a secret token (not the origin) is the gate. + head += "Access-Control-Allow-Origin: *\r\n" + head += "Access-Control-Allow-Headers: Content-Type, X-GrammarGem-Token\r\n" + head += "Access-Control-Allow-Methods: POST, GET, OPTIONS\r\n" + head += "Connection: close\r\n\r\n" + var out = Data(head.utf8) + out.append(body) + conn.send(content: out, completion: .contentProcessed { _ in conn.cancel() }) + } + + private static func reason(_ status: Int) -> String { + switch status { + case 200: return "OK" + case 204: return "No Content" + case 400: return "Bad Request" + case 401: return "Unauthorized" + case 404: return "Not Found" + case 422: return "Unprocessable Entity" + default: return "Error" + } + } +} + +/// A just-enough HTTP/1.1 request parser for small JSON POSTs. Returns nil until +/// the headers terminator (`\r\n\r\n`) has arrived; `isComplete` is false until the +/// full `Content-Length` body is buffered, so the receive loop knows to read more. +private struct HTTPRequest { + let method: String + let path: String + let headers: [String: String] + let body: Data + let isComplete: Bool + + init?(_ raw: Data) { + guard let headerEnd = raw.range(of: Data("\r\n\r\n".utf8)) else { return nil } + guard let headerString = String(data: raw[..= 2 else { return nil } + method = String(parts[0]) + path = String(parts[1]) + lines.removeFirst() + + var hdrs: [String: String] = [:] + for line in lines { + guard let colon = line.firstIndex(of: ":") else { continue } + let key = line[..= contentLength + } +} diff --git a/Sources/GrammaGem/System/Permissions.swift b/Sources/GrammarGem/System/Permissions.swift similarity index 96% rename from Sources/GrammaGem/System/Permissions.swift rename to Sources/GrammarGem/System/Permissions.swift index 044f44d..3ac8ec2 100644 --- a/Sources/GrammaGem/System/Permissions.swift +++ b/Sources/GrammarGem/System/Permissions.swift @@ -2,7 +2,7 @@ import Foundation import AppKit import ApplicationServices -/// Tracks and requests the macOS permissions GrammaGem needs to read & replace +/// Tracks and requests the macOS permissions GrammarGem needs to read & replace /// text system-wide: **Accessibility** (required) and, for the synthetic-keystroke /// clipboard fallback, **Input Monitoring** may also be prompted by the system. @MainActor diff --git a/Sources/GrammaGem/System/SnippetStore.swift b/Sources/GrammarGem/System/SnippetStore.swift similarity index 97% rename from Sources/GrammaGem/System/SnippetStore.swift rename to Sources/GrammarGem/System/SnippetStore.swift index 4d17914..a959497 100644 --- a/Sources/GrammaGem/System/SnippetStore.swift +++ b/Sources/GrammarGem/System/SnippetStore.swift @@ -13,7 +13,7 @@ struct Snippet: Codable, Identifiable, Equatable { final class SnippetStore: ObservableObject { @Published private(set) var snippets: [Snippet] - private let key = "GrammaGem.snippets" + private let key = "GrammarGem.snippets" private let d = UserDefaults.standard init() { diff --git a/Sources/GrammaGem/System/TextCapture.swift b/Sources/GrammarGem/System/TextCapture.swift similarity index 85% rename from Sources/GrammaGem/System/TextCapture.swift rename to Sources/GrammarGem/System/TextCapture.swift index 8c1a090..1ff2241 100644 --- a/Sources/GrammaGem/System/TextCapture.swift +++ b/Sources/GrammarGem/System/TextCapture.swift @@ -4,7 +4,7 @@ import ApplicationServices import Carbon.HIToolbox /// Reads the user's selected text from the frontmost app and writes a corrected -/// value back — the mechanism that makes GrammaGem work *everywhere*. +/// value back — the mechanism that makes GrammarGem work *everywhere*. /// /// Two paths, mirroring the spec (§4): /// 1. **Accessibility API** (preferred): read/write `kAXSelectedTextAttribute` @@ -24,10 +24,12 @@ final class TextCapture { enum CaptureError: LocalizedError { case noSelection case notPermitted + case secureField var errorDescription: String? { switch self { case .noSelection: return "Select some text first, then press the hotkey." - case .notPermitted: return "GrammaGem needs Accessibility permission to read and replace text." + case .notPermitted: return "GrammarGem needs Accessibility permission to read and replace text." + case .secureField: return "GrammarGem never reads password fields." } } } @@ -41,8 +43,15 @@ final class TextCapture { // MARK: - Capture /// Try AX first; on empty/unavailable, fall back to the clipboard path. + /// Refuses outright if the focused element is a password field. func capture() throws -> Capture { - if let ax = captureViaAccessibility() { + let focused = AXIsProcessTrusted() ? AX.focusedElement() : nil + if let focused, AX.isSecureTextField(focused) { + // Bail before the clipboard fallback too — never synthesize ⌘C into a + // password field. + throw CaptureError.secureField + } + if let ax = captureViaAccessibility(focused) { return ax } if let clip = try captureViaClipboard() { @@ -51,11 +60,8 @@ final class TextCapture { throw CaptureError.noSelection } - private func captureViaAccessibility() -> Capture? { - guard AXIsProcessTrusted() else { return nil } - let systemWide = AXUIElementCreateSystemWide() - - guard let focused = AX.copyElement(systemWide, kAXFocusedUIElementAttribute) else { return nil } + private func captureViaAccessibility(_ focused: AXUIElement?) -> Capture? { + guard let focused else { return nil } var valueRef: CFTypeRef? guard AXUIElementCopyAttributeValue( @@ -93,8 +99,8 @@ final class TextCapture { /// The full text value of the focused element (not just the selection). func focusedFieldText() -> (text: String, element: AXUIElement)? { guard AXIsProcessTrusted() else { return nil } - let systemWide = AXUIElementCreateSystemWide() - guard let focused = AX.copyElement(systemWide, kAXFocusedUIElementAttribute) else { return nil } + guard let focused = AX.focusedElement() else { return nil } + guard !AX.isSecureTextField(focused) else { return nil } // never fix a password field // Only treat genuine editable text roles as fixable, so we don't report // "nothing selected" failures on sliders/steppers/custom controls. diff --git a/Sources/GrammaGem/System/TextFieldScanner.swift b/Sources/GrammarGem/System/TextFieldScanner.swift similarity index 94% rename from Sources/GrammaGem/System/TextFieldScanner.swift rename to Sources/GrammarGem/System/TextFieldScanner.swift index e2c40fb..4e320ea 100644 --- a/Sources/GrammaGem/System/TextFieldScanner.swift +++ b/Sources/GrammarGem/System/TextFieldScanner.swift @@ -15,7 +15,7 @@ struct ScannedField { /// Walks the Accessibility tree of the frontmost window and collects every /// editable text element (text fields, text areas, combo/search boxes) with -/// content — so GrammaGem can check *all* the text on screen, not just the one +/// content — so GrammarGem can check *all* the text on screen, not just the one /// the cursor is in. Bounded so huge web/Electron trees can't stall the app, and /// every cross-process message has a timeout so a wedged app can't hang the scan. /// @@ -47,7 +47,9 @@ enum TextFieldScanner { if role == kAXTextFieldRole as String || role == kAXTextAreaRole as String || role == kAXComboBoxRole as String { - if let value = AX.copyString(el, kAXValueAttribute), + // Never surface password fields in the live monitor. + if !AX.isSecureTextField(el), + let value = AX.copyString(el, kAXValueAttribute), value.trimmingCharacters(in: .whitespacesAndNewlines).count >= 3 { let label = AX.copyString(el, kAXTitleAttribute) ?? AX.copyString(el, kAXDescriptionAttribute) diff --git a/Sources/GrammaGem/System/TextReplacementCoordinator.swift b/Sources/GrammarGem/System/TextReplacementCoordinator.swift similarity index 89% rename from Sources/GrammaGem/System/TextReplacementCoordinator.swift rename to Sources/GrammarGem/System/TextReplacementCoordinator.swift index 3746686..2cc0771 100644 --- a/Sources/GrammaGem/System/TextReplacementCoordinator.swift +++ b/Sources/GrammarGem/System/TextReplacementCoordinator.swift @@ -12,14 +12,19 @@ final class TextReplacementCoordinator { private let ai: AIEngine private let gate: FeatureGate private let detector: AppDetector + /// Vocabulary the LLM must reproduce verbatim (personal dictionary / style + /// guide). Read fresh per action so dictionary edits take effect immediately. + private let protectedTerms: () -> [String] init(capture: TextCapture, grammar: GrammarEngine, ai: AIEngine, - gate: FeatureGate, detector: AppDetector) { + gate: FeatureGate, detector: AppDetector, + protectedTerms: @escaping () -> [String] = { [] }) { self.capture = capture self.grammar = grammar self.ai = ai self.gate = gate self.detector = detector + self.protectedTerms = protectedTerms } /// The "fix" hotkey — instant grammar correction (Layer 1, always free). @@ -52,7 +57,7 @@ final class TextReplacementCoordinator { do { cap = try await captureOffMain() } catch { return .failed(error.localizedDescription) } do { - let result = try await ai.run(action, on: cap.text) + let result = try await ai.run(action, on: cap.text, protectedTerms: protectedTerms()) gate.recordAIActionUsed() let ok = await replaceOffMain(result, cap) return ok ? .replaced(result) : .failed("Couldn't write the result back.") @@ -73,7 +78,7 @@ final class TextReplacementCoordinator { do { cap = try await captureOffMain() } catch { return .failed(error.localizedDescription) } do { - let result = try await ai.run(action, on: cap.text) + let result = try await ai.run(action, on: cap.text, protectedTerms: protectedTerms()) gate.recordAIActionUsed() let ok = await replaceOffMain(result, cap) return ok ? .replaced(result) : .failed("Couldn't write the result back.") diff --git a/Sources/GrammarGem/System/UpdaterManager.swift b/Sources/GrammarGem/System/UpdaterManager.swift new file mode 100644 index 0000000..e4c2981 --- /dev/null +++ b/Sources/GrammarGem/System/UpdaterManager.swift @@ -0,0 +1,81 @@ +import Foundation +import Sparkle + +/// Wraps Sparkle's updater. It background-checks the appcast on grammargem.com and +/// publishes `updateAvailableVersion` when a newer, EdDSA-verified build exists, so +/// the menu bar can show an "Update available" button. Triggering an update runs +/// Sparkle's verified download + install flow. +/// +/// The updater is only started inside the packaged `.app` (where the Info.plist +/// carries `SUFeedURL` + `SUPublicEDKey`); under `swift run`, tests, or the +/// self-test binaries it stays inert so nothing crashes. +@MainActor +final class UpdaterManager: NSObject, ObservableObject { + @Published private(set) var updateAvailableVersion: String? + + private var controller: SPUStandardUpdaterController? + + override init() { + super.init() + guard Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") != nil else { + return // not the packaged app — no updater + } + controller = SPUStandardUpdaterController( + startingUpdater: true, updaterDelegate: self, userDriverDelegate: nil) + } + + var canCheckForUpdates: Bool { controller?.updater.canCheckForUpdates ?? false } + + /// User-initiated check — shows Sparkle's UI (used by the menu item and the + /// "Update" button on the availability banner). + func checkForUpdates() { + controller?.updater.checkForUpdates() + } +} + +extension UpdaterManager: SPUUpdaterDelegate { + func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { + updateAvailableVersion = item.displayVersionString + } + + func updaterDidNotFindUpdate(_ updater: SPUUpdater) { + updateAvailableVersion = nil + } +} + +// MARK: - Headless update self-test (build pipeline / CI) + +/// Checks `feedURL` for a newer version with no UI and prints the result, then +/// exits. Used to verify the appcast + signature + version-comparison path. +enum UpdaterSelfTest { + static func run(feedURL: String) { + let delegate = UpdateProbeDelegate(feed: feedURL) + UpdateProbeDelegate.retained = delegate + UpdateProbeDelegate.controller = SPUStandardUpdaterController( + startingUpdater: true, updaterDelegate: delegate, userDriverDelegate: nil) + UpdateProbeDelegate.controller?.updater.checkForUpdateInformation() + RunLoop.main.run() // Sparkle's network check needs the run loop; delegate exits. + } +} + +private final class UpdateProbeDelegate: NSObject, SPUUpdaterDelegate { + static var controller: SPUStandardUpdaterController? + static var retained: UpdateProbeDelegate? + let feed: String + init(feed: String) { self.feed = feed; super.init() } + + func feedURLString(for updater: SPUUpdater) -> String? { feed.isEmpty ? nil : feed } + + func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { + print("UPDATE_FOUND: \(item.displayVersionString)") + exit(0) + } + func updaterDidNotFindUpdate(_ updater: SPUUpdater) { + print("UPDATE_NONE") + exit(0) + } + func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { + print("UPDATE_ERROR: \(error.localizedDescription)") + exit(1) + } +} diff --git a/Tests/GrammaGemTests/GrammaGemTests.swift b/Tests/GrammaGemTests/GrammaGemTests.swift deleted file mode 100644 index 17f4c96..0000000 --- a/Tests/GrammaGemTests/GrammaGemTests.swift +++ /dev/null @@ -1,78 +0,0 @@ -import XCTest -@testable import GrammaGem - -final class GrammaGemTests: XCTestCase { - - // MARK: - Tiers / entitlements - - func testDeviceCapsMatchSpec() { - XCTAssertEqual(Tier.solo.deviceCap, 1) - XCTAssertEqual(Tier.personal.deviceCap, 2) - XCTAssertEqual(Tier.studio.deviceCap, 4) - XCTAssertEqual(Tier.free.deviceCap, 1) - } - - func testFreeEntitlementsAreLimited() { - let free = Entitlements(tier: .free) - XCTAssertFalse(free.unlimitedAIActions) - XCTAssertEqual(free.dailyAIActionCap, AppConfig.Limits.freeDailyAIActions) - XCTAssertEqual(free.dictionaryCap, AppConfig.Limits.freeDictionaryCap) - XCTAssertFalse(free.appAwareSwitching) - XCTAssertFalse(free.allModes) - XCTAssertTrue(free.unlimitedGrammar) // grammar is always free - } - - func testPaidEntitlementsUnlockEverything() { - let paid = Entitlements(tier: .personal) - XCTAssertTrue(paid.unlimitedAIActions) - XCTAssertEqual(paid.dailyAIActionCap, Int.max) - XCTAssertEqual(paid.dictionaryCap, Int.max) - XCTAssertTrue(paid.appAwareSwitching) - XCTAssertTrue(paid.allModes) - } - - // MARK: - Grammar (Harper stub) rules - - func testHarperFixesRepeatedWordAndCasing() { - let harper = HarperEngine() - let input = "hey i wanted to checkin about the the proposal" - let fixed = harper.correct(input) - XCTAssertFalse(fixed.contains("the the"), "repeated word should be collapsed") - XCTAssertTrue(fixed.contains(" I "), "lowercase pronoun i should be capitalized") - XCTAssertTrue(fixed.contains("check in"), "checkin should split into two words") - } - - func testIgnoreListSuppressesCorrection() { - let harper = HarperEngine() - harper.ignoreList = ["teh"] // pretend "teh" is an intentional brand term - let suggestions = harper.check("teh launch") - XCTAssertFalse(suggestions.contains { $0.original.lowercased() == "teh" }) - } - - // MARK: - Modes - - func testFreeTierGetsOnlyPolishMode() { - let free = ModeRegistry.available(for: .free) - XCTAssertEqual(free.map(\.id), ["polish"]) - let paid = ModeRegistry.available(for: .studio) - XCTAssertGreaterThan(paid.count, 1) - XCTAssertTrue(paid.contains { $0.id == "email" }) - } - - func testAppAwareModeMapping() { - XCTAssertEqual(ModeRegistry.mode(forBundleID: "com.apple.mail")?.id, "email") - XCTAssertEqual(ModeRegistry.mode(forBundleID: "com.microsoft.VSCode")?.id, "code") - XCTAssertNil(ModeRegistry.mode(forBundleID: "com.unknown.app")) - } - - // MARK: - Device fingerprint - - func testDeviceFingerprintIsStableAndHashed() { - let a = DeviceFingerprint.stableID() - let b = DeviceFingerprint.stableID() - XCTAssertEqual(a, b, "fingerprint must be stable across calls") - XCTAssertFalse(a.isEmpty) - // SHA-256 hex is 64 chars (unless the random fallback path is hit). - XCTAssertTrue(a.count == 64 || a.count == UUID().uuidString.count) - } -} diff --git a/Tests/GrammarGemTests/GrammarGemTests.swift b/Tests/GrammarGemTests/GrammarGemTests.swift new file mode 100644 index 0000000..0341edd --- /dev/null +++ b/Tests/GrammarGemTests/GrammarGemTests.swift @@ -0,0 +1,222 @@ +import XCTest +@testable import GrammarGem + +final class GrammarGemTests: XCTestCase { + + // MARK: - Tiers / entitlements + + func testDeviceCapsMatchSpec() { + XCTAssertEqual(Tier.solo.deviceCap, 1) + XCTAssertEqual(Tier.personal.deviceCap, 2) + XCTAssertEqual(Tier.studio.deviceCap, 4) + XCTAssertEqual(Tier.free.deviceCap, 1) + } + + func testFreeEntitlementsAreLimited() { + let free = Entitlements(tier: .free) + XCTAssertFalse(free.unlimitedAIActions) + XCTAssertEqual(free.dailyAIActionCap, AppConfig.Limits.freeDailyAIActions) + XCTAssertEqual(free.dictionaryCap, AppConfig.Limits.freeDictionaryCap) + XCTAssertFalse(free.appAwareSwitching) + XCTAssertFalse(free.allModes) + XCTAssertTrue(free.unlimitedGrammar) // grammar is always free + } + + func testPaidEntitlementsUnlockEverything() { + let paid = Entitlements(tier: .personal) + XCTAssertTrue(paid.unlimitedAIActions) + XCTAssertEqual(paid.dailyAIActionCap, Int.max) + XCTAssertEqual(paid.dictionaryCap, Int.max) + XCTAssertTrue(paid.appAwareSwitching) + XCTAssertTrue(paid.allModes) + } + + // MARK: - Grammar (Harper stub) rules + + func testHarperFixesRepeatedWordAndCasing() { + let harper = HarperEngine() + let input = "hey i wanted to checkin about the the proposal" + let fixed = harper.correct(input) + XCTAssertFalse(fixed.contains("the the"), "repeated word should be collapsed") + XCTAssertTrue(fixed.contains(" I "), "lowercase pronoun i should be capitalized") + XCTAssertTrue(fixed.contains("check in"), "checkin should split into two words") + } + + func testIgnoreListSuppressesCorrection() { + let harper = HarperEngine() + harper.ignoreList = ["teh"] // pretend "teh" is an intentional brand term + let suggestions = harper.check("teh launch") + XCTAssertFalse(suggestions.contains { $0.original.lowercased() == "teh" }) + } + + /// Real Harper does true grammar NSSpellChecker can't: subject-verb + /// agreement and verb-form correction, applied cleanly in place. + func testHarperRealGrammarCorrections() { + let harper = HarperEngine() + let fixed = harper.correct("She dont like it and she have went home.") + XCTAssertTrue(fixed.contains("don't"), "dont -> don't") + XCTAssertTrue(fixed.contains("has"), "have -> has (subject-verb agreement)") + XCTAssertTrue(fixed.contains("gone"), "went -> gone (past participle)") + XCTAssertFalse(fixed.contains("went"), "the wrong verb form should be gone") + } + + /// The trust bug: a mid-sentence spelling fix must keep the original casing. + func testSpellingReplacementPreservesCase() { + XCTAssertEqual(SystemGrammarEngine.matchingCase(of: "problemm", in: "Problem"), "problem") + XCTAssertEqual(SystemGrammarEngine.matchingCase(of: "Teh", in: "the"), "The") + XCTAssertEqual(SystemGrammarEngine.matchingCase(of: "teh", in: "the"), "the") + XCTAssertEqual(SystemGrammarEngine.matchingCase(of: "HELLOO", in: "hello"), "HELLO") + } + + /// Harper returns char (Unicode scalar) offsets; they must map exactly onto + /// UTF-16 NSRange offsets even past multi-UTF-16-unit scalars (emoji). + func testHarperCharOffsetsMapToUTF16() { + let text = "😀 teh" // 😀 = 1 scalar / 2 UTF-16 units + let range = HarperEngine.utf16Range(scalarStart: 2, scalarLen: 3, in: text) + XCTAssertEqual(range, NSRange(location: 3, length: 3)) + XCTAssertEqual((text as NSString).substring(with: range!), "teh") + } + + // MARK: - Value loop (streak / subscription-avoided / milestones) + + @MainActor + func testValueLoopMetrics() { + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd" + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.timeZone = .current + let cal = Calendar.current + let now = Date() + func key(_ daysAgo: Int) -> String { + fmt.string(from: cal.date(byAdding: .day, value: -daysAgo, to: now)!) + } + + // today + yesterday + 2-days-ago = streak of 3 (the day-4 entry is broken off). + let perDay = [key(0): 2, key(1): 1, key(2): 5, key(4): 1] + XCTAssertEqual(UsageStats.streak(perDay: perDay, asOf: now), 3) + XCTAssertEqual(UsageStats.streak(perDay: [:], asOf: now), 0) + // Used yesterday but not yet today — streak is still alive. + XCTAssertEqual(UsageStats.streak(perDay: [key(1): 1], asOf: now), 1) + + // ~1 year of ownership ≈ one year of the subscription you didn't pay. + XCTAssertEqual( + UsageStats.subscriptionAvoidedUSD(firstUseDay: key(365), asOf: now, annualPrice: 144), 144) + XCTAssertEqual( + UsageStats.subscriptionAvoidedUSD(firstUseDay: nil, asOf: now, annualPrice: 144), 0) + + let m = UsageStats.nextMilestone(corrections: 50) + XCTAssertEqual(m.target, 100) + XCTAssertEqual(m.progress, 0.5, accuracy: 0.001) + XCTAssertEqual(UsageStats.nextMilestone(corrections: 100).target, 500) + } + + // MARK: - MLX on-device LLM (integration; skipped unless the model is present) + + /// Loads the real downloaded model and runs one generation. Skipped in CI / + /// on machines without the weights. Run with the model present to verify the + /// MLX integration end-to-end. + func testMLXRealGenerationIfModelPresent() async throws { + // Opt-in: `swift test` (CLI) can't place mlx-swift's metallib, so this is + // gated. Run it via the app's `--mlx-selftest`, or set GRAMMARGEM_MLX_TEST=1 + // with the metallib colocated. See scripts/build-metallib.sh. + guard ProcessInfo.processInfo.environment["GRAMMARGEM_MLX_TEST"] == "1" else { + throw XCTSkip("set GRAMMARGEM_MLX_TEST=1 (metallib colocated) to run the MLX integration test") + } + guard let dir = ModelManager.completedModelDirectory(repo: AppConfig.Model.defaultRepo) else { + throw XCTSkip("on-device model not downloaded; skipping MLX integration test") + } + let engine = MLXEngine(modelDirectoryProvider: { dir }) + XCTAssertTrue(engine.isReady) + let out = try await engine.run(.rewriteClarity, + on: "me and him was going to the store for to buy some milks") + print("MLX rewriteClarity -> \(out)") + XCTAssertFalse(out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + // MARK: - Modes + + func testFreeTierGetsOnlyPolishMode() { + let free = ModeRegistry.available(for: .free) + XCTAssertEqual(free.map(\.id), ["polish"]) + let paid = ModeRegistry.available(for: .studio) + XCTAssertGreaterThan(paid.count, 1) + XCTAssertTrue(paid.contains { $0.id == "email" }) + } + + func testAppAwareModeMapping() { + XCTAssertEqual(ModeRegistry.mode(forBundleID: "com.apple.mail")?.id, "email") + XCTAssertEqual(ModeRegistry.mode(forBundleID: "com.microsoft.VSCode")?.id, "code") + XCTAssertNil(ModeRegistry.mode(forBundleID: "com.unknown.app")) + } + + // MARK: - AIPrompts (backend-neutral seam) + + func testProtectedTermsInjectedIntoSystemPrompt() { + let base = AIPrompts.systemPrompt(for: .rewriteClarity) + let withTerms = AIPrompts.systemPrompt(for: .rewriteClarity, protectedTerms: ["GrammarGem", "Harper"]) + XCTAssertFalse(base.contains("GrammarGem")) + XCTAssertTrue(withTerms.contains("\"GrammarGem\"")) + XCTAssertTrue(withTerms.contains("\"Harper\"")) + XCTAssertTrue(withTerms.contains("verbatim")) + } + + func testProtectedTermsClauseSkipsWhenEmpty() { + let blank = AIPrompts.systemPrompt(for: .rewrite, protectedTerms: ["", " "]) + XCTAssertEqual(blank, AIPrompts.systemPrompt(for: .rewrite)) + } + + func testMaxTokensHonorsModeLengthCap() { + // Default actions get the full budget… + XCTAssertEqual(AIPrompts.maxTokens(for: .rewriteClarity), 512) + // …but a short-form Mode (Team Chat, lengthCap 60) gets far fewer. + let slack = ModeRegistry.slack + XCTAssertNotNil(slack.lengthCap) + let capped = AIPrompts.maxTokens(for: .applyMode(slack)) + XCTAssertLessThan(capped, 512) + XCTAssertGreaterThanOrEqual(capped, 64) + // A mode with no cap (Polish) falls back to the default budget. + XCTAssertEqual(AIPrompts.maxTokens(for: .applyMode(ModeRegistry.polish)), 512) + } + + func testCleanStripsPreambleAndQuotes() { + XCTAssertEqual(AIPrompts.clean("Sure, here's the rewrite: Hello world"), "Hello world") + XCTAssertEqual(AIPrompts.clean("\"Quoted result\""), "Quoted result") + XCTAssertEqual(AIPrompts.clean(" already clean "), "already clean") + } + + func testParseToneIsRobust() { + XCTAssertEqual(AIPrompts.parseTone("professional"), .professional) + XCTAssertEqual(AIPrompts.parseTone(" Friendly.\n"), .friendly) + XCTAssertEqual(AIPrompts.parseTone("the tone is academic here"), .academic) + XCTAssertEqual(AIPrompts.parseTone("???"), .professional) // safe fallback + } + + // MARK: - Custom modes + + @MainActor + func testCustomModeRejectsEmptyPromptAndDuplicates() { + // Deterministic regardless of prior runs (the store persists to UserDefaults). + UserDefaults.standard.removeObject(forKey: "GrammarGem.customModes") + defer { UserDefaults.standard.removeObject(forKey: "GrammarGem.customModes") } + + let store = CustomModesStore() + XCTAssertFalse(store.addCustom(name: "Brand Voice", prompt: " "), + "empty prompt must be rejected") + XCTAssertTrue(store.addCustom(name: "Brand Voice", prompt: "Write in Acme's warm voice.")) + XCTAssertFalse(store.addCustom(name: "brand voice", prompt: "different prompt"), + "duplicate name (case-insensitive) must be rejected") + XCTAssertFalse(store.addCustom(name: "Polish", prompt: "collides with a built-in"), + "name colliding with a built-in mode must be rejected") + } + + // MARK: - Device fingerprint + + func testDeviceFingerprintIsStableAndHashed() { + let a = DeviceFingerprint.stableID() + let b = DeviceFingerprint.stableID() + XCTAssertEqual(a, b, "fingerprint must be stable across calls") + XCTAssertFalse(a.isEmpty) + // SHA-256 hex is 64 chars (unless the random fallback path is hit). + XCTAssertTrue(a.count == 64 || a.count == UUID().uuidString.count) + } +} diff --git a/docs/grammarly-value-plan.md b/docs/grammarly-value-plan.md new file mode 100644 index 0000000..11bf563 --- /dev/null +++ b/docs/grammarly-value-plan.md @@ -0,0 +1,204 @@ +# Grammarly Value Teardown -> GrammarGem Plan + +*Senior product + Swift engineering review. Every claim below is grounded in the current GrammarGem source. File references use absolute paths; the load-bearing bug is the mis-casing in `SystemGrammarEngine.swift:42-45`.* + +--- + +## 1. Executive summary — what makes Grammarly FEEL valuable (priority order) + +Grammarly's perceived value is NOT its grammar engine. It is a stack of interaction and psychology layers on top of a competent engine. Ranked by leverage for a one-time-purchase, on-device Mac utility: + +1. **Trustworthy correction quality (the foundation).** Every felt-value mechanic above it collapses if a fix is wrong. Grammarly's "accept-accept-accept" rhythm only works because in-place application is reliable and casing/context is preserved. GrammarGem's engine currently *demonstrably violates this* (`problemm` -> `Problem` mid-sentence). Fix this first; nothing else matters until it's solid. + +2. **The fix-as-button atomic loop: read the fix and apply it in one motion.** Grammarly's clickable green replacement chip collapses diagnosis + remedy + action into a single low-friction act, with a cheap, always-present dismiss so saying no is as easy as saying yes. GrammarGem has an `Apply` button but no dismiss/ignore/add-to-dictionary, no keyboard accept, and the apply path is brittle (whole-field AX overwrite). + +3. **Categorization + counts + a score: turning a wall of red into a finite, beatable list.** Four named, color-coded buckets, per-category count chips, one running issue badge, and a 0-100 score convert dread into a checklist with a meter that only goes up. GrammarGem has a raw issue count (3 colors) and *zero* score concept. + +4. **The rigged first-run "wow": guaranteed visible win in under 60 seconds.** A pre-seeded, error-salted demo doc means the product proves itself before asking for anything. GrammarGem's onboarding only handles Accessibility + model download — it never demonstrates the correction loop at all. + +5. **Ongoing perceived value without surveillance: progress you can see.** Grammarly uses cloud percentiles + weekly behavioral emails. GrammarGem can recreate the *feeling* — self-vs-self, fully on-device — with a local score trend, streaks, milestones, and a "subscription you didn't pay" money figure. The plumbing (`UsageStats.perDay`) already exists. + +6. **The generative "writing partner" layer: hand me the better sentence, not just the flag.** Sentence rewrites, tone detection/adjustment, conciseness. This is real value but is currently 100% simulated (`MLXEngine.swift` is a string-hack stub) and gated by a daily counter. It is the right *paid* tier, but it must produce real output before it can be sold. + +The strategic asymmetry to exploit throughout: every Grammarly value moment carries an implicit "and you'll pay again next year." GrammarGem's identical mechanics should be framed "yours once, forever, on this Mac." + +--- + +## 2. Mechanic-by-mechanic: how Grammarly does it -> how GrammarGem should do it + +### Mechanic 1 — Trustworthy correction quality (see §3 for the full engineering plan) +- **Grammarly:** ranks by confidence, suppresses low-confidence edits, never garbles surrounding text, preserves casing, and limits bulk-apply to high-confidence objective mechanics only. +- **GrammarGem today:** `SystemGrammarEngine` blindly trusts `NSSpellChecker.correction(forWordRange:)`, which is sentence-position-aware and capitalizes a word it thinks starts a sentence — producing `problemm` -> `Problem` mid-sentence (`SystemGrammarEngine.swift:42-45`). `Suggestion` has no confidence/severity/alternatives (`Models.swift:48-59`). Default `correct()` applies *every* suggestion including no-op grammar flags where `replacement == original` (`GrammarEngine.swift:22-29`). +- **Do:** real Harper FFI; case/whitespace post-processing; add `confidence`/`severity`/`alternatives` to `Suggestion`; never auto-apply no-ops or low-confidence flags. +- **Files:** `SystemGrammarEngine.swift`, `HarperEngine.swift`, `GrammarEngine.swift`, `Models.swift`, `harper-ffi/`. +- **Impact: high · Effort: L** + +### Mechanic 2 — The fix-as-button atomic loop (+ dismiss / ignore / add-to-dictionary) +- **Grammarly:** the suggested word *is* the button (green = go/safe/done); an always-present cheap dismiss (trash/Esc) and add-to-dictionary make accepting feel safe; Tab-to-navigate / Enter-to-accept / Esc-to-dismiss removes the mouse round-trip. +- **GrammarGem today:** `FieldCard` shows a message + one `original -> replacement` + a small `Apply` button (`LiveCheckView.swift:104-119`). There is **no dismiss, no ignore, no add-to-dictionary, no keyboard accept.** Per-issue `apply()` bails silently if the field changed at all since the snapshot (`LiveMonitor.swift:82-89`), and even single-issue apply rewrites the *whole* AX value (`LiveMonitor.swift:101-103`), risking caret/undo/formatting loss. +- **Do (realistic for a cross-app AX tool — we cannot draw a green chip inside arbitrary apps):** + - Make the replacement text itself the primary tappable control in the card (the chip *is* the fix), styled by category color. + - Add a per-suggestion overflow: **Dismiss** (model a `dismissedIDs: Set` on the monitor so it survives re-scans), **Ignore word** / **Add to dictionary** (route into `PersonalDictionary` -> `ignoreList`), gated by `freeDictionaryCap`. + - Add **keyboard flow inside the Live Check panel**: ↑/↓ or Tab to move selection, Return to apply, Delete/Esc to dismiss. This is fully feasible because the panel is our own SwiftUI window. + - Replace whole-field AX write with **targeted span insertion** where the element supports `kAXSelectedTextRangeAttribute` + `kAXSelectedTextAttribute` (set selection to the issue range, then set selected text to the replacement) — preserves caret and undo. Fall back to whole-value only when selection-set is unsupported. + - Make per-issue apply **rebase** instead of bail: if the live value changed, re-locate the span by matching `original` near the old offset rather than refusing to act. +- **Files:** `LiveCheckView.swift`, `LiveMonitor.swift`, `Models.swift`, `PersonalDictionary.swift`, `AppState.swift`, `TextCapture`. +- **Impact: high · Effort: M** + +### Mechanic 3 — Categorization, counts, and an on-device document score +- **Grammarly:** four named buckets (Correctness/Clarity/Engagement/Delivery) each with a color and a count chip that doubles as a filter; one master running badge; a 0-100 percentile score that ticks up on every accept; cards ordered by severity (objective errors first) so the early wins build trust. +- **GrammarGem today:** `GrammarKind` has 6 cases but `.punctuation` is dead; the panel reduces everything to 3 dot colors (`LiveCheckView.swift:136-142`); `issueCount` is a raw sum (`LiveMonitor.swift:32`); there is **no score, no per-category chips, no severity ordering, no filter**. +- **Do (on-device, no cloud, no percentile-vs-others):** + - Introduce GrammarGem's own named buckets that fit the engine: e.g. **Correctness** (spelling/grammar/punctuation), **Clarity** (phrasing/conciseness — from the real LLM), **Polish** (repetition/capitalization/style). Use original color semantics (do NOT clone Grammarly's exact red/blue/green/purple mapping or wording). + - Add **count chips per bucket** in the panel header that filter the card list; keep the menu-bar badge as the master tally (`MenuBarLabel`). + - Compute a **local cleanliness score** at check-time: a deterministic function of issues-per-100-words by severity (e.g. `100 - weighted_issues`). It is honest because it's defined relative to *this document*, not a hidden user corpus. Show it on the card header and trend it on the dashboard. + - **Order cards by severity then document position** (objective spelling/grammar first) so the first accept is a high-confidence win. + - Critical: the score must reflect the *honest* free promise — grammar/spelling is unlimited and fully resolvable, so a free user CAN reach a clean score. Do **not** manufacture a permanently-nonzero count to force upgrades (that's Grammarly's locked-bucket trick and it contradicts our positioning — see §5). +- **Files:** `Models.swift` (`GrammarKind` cleanup, add `severity`), `LiveMonitor.swift`, `LiveCheckView.swift`, `MenuBarContent.swift`, `DashboardView.swift`, a new `WritingScore` helper. +- **Impact: high · Effort: M** + +### Mechanic 4 — The first-run "wow" (see §4 for the full sequence) +- **Grammarly:** land in an error-salted demo doc -> "we found N issues" -> one-click fixes vanish underlines + decrement counter -> goal setup -> install-everywhere prompt at the dopamine peak. +- **GrammarGem today:** `OnboardingView` only covers Accessibility + model download (`OnboardingView.swift`); it never shows the correction loop. +- **Do:** add an in-app demo step with a pre-seeded sample, a "we found N things to fix" reveal, and a satisfying one-click-to-clean animation — *before* asking for nothing-but-praise (no install-everywhere pressure; we're already system-wide). Detail in §4. +- **Files:** `OnboardingView.swift`, plus a small `DemoDocument` view reusing the highlight + apply UI. +- **Impact: high · Effort: M** + +### Mechanic 5 — Ongoing perceived value (privacy-native, self-vs-self) +- **Grammarly:** 0-100 score, weekly Insights email ("more productive than X% of users"), lifetime words-checked counter, streaks — all cloud + behavioral-email, the exact surveillance we market against. +- **GrammarGem today:** `UsageStats` tracks `totalCorrections`, `totalAIActions`, `totalWords`, and `perDay` (`UsageStats.swift`); the dashboard shows four tiles + a 7-bar chart, but **no score, no streak, no week-over-week, no milestone, no money-saved figure**. `estimatedMinutesSaved` is a crude untuned constant (`8s/correction + 45s/AI-action`). Stats are invisible outside the main window. +- **Do (all local, no telemetry, stops nagging the moment they own it):** + - **Local writing-score trend** on the dashboard (from Mechanic 3), compared only to the user's own past. + - **Streak + milestones** from existing `perDay` data ("7-day streak", "1,000th correction", "100k words polished") — cheap, high word-of-mouth, and the right re-engagement hook for a no-renewal product. + - **"Subscription you didn't pay" figure** — translate usage into money: the marketing site already hammers "~\$144/yr vs pay once," but the *app* never does. Show "You've polished N times — that's the price of GrammarGem recovered M times over," computed locally. This is the strongest one-time-purchase value teaser and it lives where the user actually is. + - **Week-over-week delta** on the bar chart and a lightweight in-app weekly recap card (NOT an email; no pipeline, no surveillance). + - **Surface one stat in the menu bar dropdown** (the most-seen surface) so value reminders don't require opening the dashboard. + - Tune `estimatedMinutesSaved` to scale by words changed / action type so the headline "was it worth it?" number is credible. +- **Files:** `UsageStats.swift`, `DashboardView.swift`, `MenuBarContent.swift`, `web/src/lib/content.ts` (replace PLACEHOLDER stats/testimonials before launch). +- **Impact: high · Effort: M** + +### Mechanic 6 — The generative "writing partner" layer (real LLM, honest gating) +- **Grammarly:** sentence rewrites ("Replace with This Version" + Highlight-Changes diff), tone detection (emoji + label), tone adjustment, conciseness, GrammarlyGO — all one-click, reversible, before/after. +- **GrammarGem today:** `MLXEngine` is a stub doing `replacingOccurrences` string hacks (`MLXEngine.swift`); `tidy()` even re-introduces casing risk; `detectTone` keys off substrings. `ModelManager` downloads real weights but nothing loads them. Gated by `freeDailyAIActions = 10` shared across rewrite/tone/Ask/translate. +- **Do:** + - Wire the **real MLX model** from `ModelManager.modelsDirectory` (the download is currently a dead-end); uncomment `mlx-swift` in `Package.swift`. + - Adopt the **one-click + reversible + before/after diff** delivery contract for every rewrite — this is what neutralizes "the AI is taking over my voice." + - Keep the **honest quantity gate**: free users get *real, full-quality* output, just rationed (the daily counter), plus Polish mode only. This is the parity-respecting tease — satisfaction-driven ("I keep hitting the limit / I want my own modes / I want it on my other Mac"), not anxiety-driven. + - Real **tone detection** (label + emoji) is the highest-value non-grammar feature; it delivers value on grammatically perfect text. Build it once the model is live. +- **Files:** `MLXEngine.swift`, `AIEngine.swift`, `ModelManager.swift`, `Package.swift`, `FeatureGate.swift`, `TextReplacementCoordinator.swift`, `WritingModesView.swift`. +- **Impact: high · Effort: L** (model integration is the long pole) + +--- + +## 3. Correction-QUALITY: the path to trustworthy fixes (this underpins ALL perceived value) + +The engine is the load-bearing wall. Today it is `NSSpellChecker` + a ~6-rule regex stub, and it ships a trust-destroying casing bug. Here is the ordered path. + +### 3.1 Fix the casing/whitespace bug NOW (cheap, immediate) +Root cause: `SystemGrammarEngine.swift:42-45` blindly uses `checker.correction(forWordRange:)`, which is sentence/position-aware and returns a capitalized guess for a token it believes starts a sentence — so a mid-sentence `problemm` comes back `Problem`. + +**Post-process every spelling replacement to preserve the original token's casing** unless the token is a true sentence start or proper noun: +- If `original` is all-lowercase, lowercase the replacement's first letter. +- If `original` is Capitalized (single leading cap), match that. +- If `original` is ALL-CAPS, upper-case the replacement. +- Only adopt the corrector's case when the token is at a genuine sentence boundary (preceded by `.?!` + space, or string start). Also trim/normalize whitespace so a replacement never injects a stray leading/trailing space. + +This is a ~20-line transcase helper applied at suggestion-build time. It removes the single most visible trust failure before any deeper work. + +### 3.2 Stop auto-applying garbage +- `GrammarEngine.correct()` (`GrammarEngine.swift:22-29`) applies **every** suggestion including grammar flags where `replacement == original` (the no-op flags emitted at `SystemGrammarEngine.swift:51-54` and `:67-70`). Filter these out of the bulk path entirely — a no-op flag must never be part of "Fix all," and a low-confidence guess must never auto-apply. +- "Fix all" should be **trust-scoped like Grammarly's bulk accept**: only high-confidence objective mechanics (spelling/grammar/punctuation), never subjective rewrites. + +### 3.3 Enrich the data model +`Suggestion` (`Models.swift:48-59`) needs: +- `confidence: Double` (gate auto-apply below a threshold; show low-confidence ones as optional, never bulk-applied). +- `severity` (drives ordering and the score weighting). +- `alternatives: [String]` — `NSSpellChecker.guesses()` already returns multiple candidates but only `.first` is kept (`SystemGrammarEngine.swift:41`); surface the rest so the card can offer "other suggestions." +- `ruleID`/`source` — so feedback (accept/dismiss) can later tune ranking and so we can attribute a fix to Harper vs NSSpellChecker. + +### 3.4 Integrate the real Harper core (the big precision unlock) +`harper-ffi/` is an empty README; `Package.swift` comments out all real deps. Stand up the Rust crate behind a C-FFI static lib (`harper_check(text) -> spans`), map spans into `Suggestion`, and keep the protocol boundary so the rest of the app is unchanged. This brings real subject-verb agreement, article/preposition usage, comma/apostrophe rules, commonly-confused pairs, etc. — the category coverage NSSpellChecker structurally cannot provide. De-dup against NSSpellChecker as today (`overlaps`), but prefer Harper where both fire. + +### 3.5 Populate the dead `.punctuation` category +`.punctuation` is declared but never emitted, yet punctuation is one of the highest-volume real-world error classes. Harper covers most of it; this fills the gap and makes the new category chips (Mechanic 3) meaningful. + +### 3.6 Incremental, async, grapheme-safe checking +- Today: whole-document, synchronous, `DispatchQueue.main.sync` on a 1.6s poll, truncated at 4000 chars (`LiveMonitor.swift:46,174`), frontmost window only. Move to **debounced incremental re-check of changed spans** with caching of unchanged ranges; keep the heavy AX traversal off-main (already done) but get the spell/grammar pass off the main thread too once Harper is FFI-based (no `NSSpellChecker` main-thread requirement). +- Validate that replacement sub-ranges fall on **grapheme boundaries** (emoji/combining marks) before applying — only a coarse bounds check exists today (`SystemGrammarEngine.swift:63`). + +### 3.7 Feedback loop +Wire dismiss / add-to-dictionary (Mechanic 2) back into ranking so repeated false positives stop nagging — this is the "it learns" trust signal. Today `ignoreList` only suppresses; long-term, dismiss counts per `ruleID` can down-rank a noisy rule. + +**Sequencing:** §3.1 + §3.2 this week (trust-critical, tiny). §3.3 alongside Mechanic 2. §3.4–§3.6 are the real Harper milestone. §3.7 last. + +--- + +## 4. The first-run "wow" sequence for GrammarGem (original copy) + +Goal: a guaranteed visible win in under 60 seconds, *inside our own window* (we don't need Grammarly's blank-canvas demo trick or its install-everywhere upsell — we're already system-wide). Onboarding today (`OnboardingView.swift`) skips this entirely. + +Proposed flow: + +1. **Welcome (1 line):** "A private writing assistant that lives in your menu bar and fixes text in every app — all on this Mac." (existing copy is close; keep it.) + +2. **The rigged win — a live mini-demo BEFORE any permission ask.** Show a short pre-written paragraph salted with obvious, safe errors (a misspelling, a repeated word, a lowercase "i", a missing period). Render it with the highlight UI already built in `LiveCheckView.highlighted(...)`. Above it: **"We spotted N things to tidy up."** (count = guaranteed, because the sample is fixed.) + +3. **One satisfying click.** A single "Clean it up" button animates the highlights resolving one by one; the count ticks to 0; the paragraph visibly improves. This is the operant loop — trivial action, immediate visible reward — experienced once, on rails, with zero risk. (Reuse the apply path; it's a local string, not a live AX element, so it's completely safe.) + +4. **The reveal:** "That's GrammarGem. It does this in Mail, Slack, Notes, your browser — everywhere you type." Then the *first* ask: **Accessibility** (the existing step 2 / `stepCard`), now framed as "Turn it on everywhere." + +5. **Optional model step:** keep the existing "Download the on-device model (optional) — grammar works instantly with no download" card. Frame the AI features as a bonus, not a blocker. + +6. **No install-everywhere nag, no timed discount, no streak guilt.** End on a calm "You're all set" with one honest line about ownership: "Yours once. No subscription, nothing leaves your Mac." + +Guardrails: the demo must use *original* sample text (not Grammarly's), and the count/score must be real (computed by our engine on the sample), never theatrical. + +--- + +## 5. Do NOT copy (dark patterns + claims we can't truthfully make) + +These are Grammarly mechanics that either depend on infrastructure we reject or that would corrode the trust/privacy moat that *is* the product. + +1. **The premium-issue anxiety counter ("14 advanced issues you can't fix").** It works by detecting problems the engine deliberately won't fix. It clashes head-on with our promise that grammar/spelling/punctuation is unlimited and fully resolvable for free. Do not manufacture a permanently-nonzero count or a "score capped until you upgrade" gate. + +2. **Two-tier gold-vs-red underlines (a second class of locked grammar issues).** Our underlines are all free and all resolvable; there is no second class to color differently. Inventing one betrays the core differentiator. + +3. **Blur/lock a result we already computed.** On-device + open-source, hiding output we generated is both trivially bypassable and brand-corrosive. Gate by **quantity/frequency** (the daily counter) and **breadth** (modes, multi-device, snippets, larger model) — never by hiding the quality of a single result. + +4. **Cloud percentile comparisons ("more productive than X% of users").** Requires an account + aggregate corpus + telemetry — the exact surveillance we market against. Replace with **self-vs-self** local trends. + +5. **Behavioral weekly emails / re-nag pipeline.** For a one-time purchase there is no subscription to keep warm; post-purchase nagging is pure annoyance. Any weekly recap must be **in-app, local, opt-in, and stop entirely once they own it.** + +6. **Countdown/limited-time discount urgency** (Grammarly's Day-7 40% timer). Manufactured scarcity undercuts the honest "buy once, fair price" positioning. + +7. **Lifetime "total words checked" framed as cloud sunk-cost-to-lose.** A local milestone counter is fine and good; framing it as "you'll lose your history if you leave" is a retention dark pattern with no place in a no-renewal product. + +8. **Unsubstantiated social proof.** `web/src/lib/content.ts` STATS (200k+ downloads, 4.9 rating) and all TESTIMONIALS are explicitly flagged PLACEHOLDER. Do not ship these as fact — replace with real numbers or remove before launch. + +Positive reframe to keep: surface locked paid features (modes, app-aware, snippets, larger model) honestly — "Unlock all of this once. Yours forever, no subscription." Same mechanism (visible locked features), stronger close (no recurring cost objection). + +--- + +## 6. Prioritized roadmap + +### NOW (trust + the atomic loop — ship-blockers for credibility) +1. **Fix the casing/whitespace post-processing bug** (§3.1). `SystemGrammarEngine.swift`. *high / S.* +2. **Stop bulk-applying no-ops and low-confidence guesses** (§3.2). `GrammarEngine.swift`, `SystemGrammarEngine.swift`. *high / S.* +3. **Dismiss / Ignore / Add-to-dictionary + keyboard accept in the Live Check panel** (Mechanic 2). `LiveCheckView.swift`, `LiveMonitor.swift`, `Models.swift`, `PersonalDictionary.swift`. *high / M.* +4. **Targeted span apply via AX selection instead of whole-field overwrite; rebase instead of bail** (Mechanic 2). `LiveMonitor.swift`, `TextCapture`. *high / M.* +5. **First-run rigged-win demo** (§4). `OnboardingView.swift`. *high / M.* + +### NEXT (organize + the value loop) +6. **Named buckets + per-category count chips + severity ordering + filter** (Mechanic 3). `Models.swift`, `LiveMonitor.swift`, `LiveCheckView.swift`. *high / M.* +7. **On-device writing score (per-doc) + dashboard trend** (Mechanics 3 & 5). `WritingScore` helper, `LiveCheckView.swift`, `DashboardView.swift`. *high / M.* +8. **Streaks + milestones + "subscription you didn't pay" money figure + menu-bar stat** (Mechanic 5). `UsageStats.swift`, `DashboardView.swift`, `MenuBarContent.swift`. *high / M.* +9. **Enrich `Suggestion` with confidence/severity/alternatives + "other suggestions" in card** (§3.3). `Models.swift`, `SystemGrammarEngine.swift`, `LiveCheckView.swift`. *medium / M.* + +### LATER (real engine + real partner layer) +10. **Real Harper FFI integration + populate `.punctuation` + grapheme safety + incremental async re-check** (§3.4–§3.6). `harper-ffi/`, `HarperEngine.swift`, `SystemGrammarEngine.swift`, `LiveMonitor.swift`, `Package.swift`. *high / L.* +11. **Wire the real MLX model (load downloaded weights) + one-click/reversible/before-after diff for rewrites + real tone detection** (Mechanic 6). `MLXEngine.swift`, `ModelManager.swift`, `Package.swift`, `TextReplacementCoordinator.swift`. *high / L.* +12. **Replace PLACEHOLDER web social proof/testimonials before launch** (§5). `web/src/lib/content.ts`. *medium / S.* + +--- + +*Throughline: ship trust first (correction quality + a safe, reversible accept loop), then make the work feel finite and rewarding (buckets, counts, a local score), then sell ongoing value honestly (self-vs-self progress, money saved, lifetime ownership) — and build the generative partner tier on a real model with a reversible, before/after contract. Every Grammarly mechanic translates if you swap cloud-surveillance for on-device self-comparison and recurring-nag for one-time honesty.* diff --git a/harper-ffi/Cargo.lock b/harper-ffi/Cargo.lock new file mode 100644 index 0000000..0d8ebd8 --- /dev/null +++ b/harper-ffi/Cargo.lock @@ -0,0 +1,5617 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ammonia" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" +dependencies = [ + "cssparser", + "html5ever", + "maplit", + "tendril", + "url", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atomic_float" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628d228f918ac3b82fe590352cc719d30664a0c13ca3a60266fe02c7132d480a" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "serde", + "unty", +] + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.2", + "shlex 1.3.0", + "syn", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blanket" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56791e4bd64c99fc361e01008f45c984baa93f12a0957d1b3c51dd2c6baab453" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "burn" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0291ea5c68786545e239a02f63331cfe39da7485164ae05197d5be6f148d0557" +dependencies = [ + "burn-autodiff", + "burn-candle", + "burn-core", + "burn-cuda", + "burn-ndarray", + "burn-nn", + "burn-optim", + "burn-rocm", + "burn-router", + "burn-store", + "burn-wgpu", +] + +[[package]] +name = "burn-autodiff" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "917423a74bf4d39f17a6799089869648e3d2b6ac89d93901aab4aeb9a7f82138" +dependencies = [ + "burn-common", + "burn-tensor", + "derive-new", + "hashbrown 0.15.5", + "log", + "num-traits", + "portable-atomic", + "spin", +] + +[[package]] +name = "burn-candle" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2891811d41ae30b5f1f660e7615b757b2cb4128af5e311b213656de3875e4acb" +dependencies = [ + "burn-common", + "burn-tensor", + "candle-core", + "derive-new", + "half", +] + +[[package]] +name = "burn-common" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb445304e4f91f8633d23c9a5258cd93639d13ce2ee47d4821fd519b683bf02" +dependencies = [ + "cubecl-common", + "rayon", + "serde", +] + +[[package]] +name = "burn-core" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c93e754864080a8c27b9a47e3b6f7d79013cf82c9ce00ed57c9ba51a3e34c5" +dependencies = [ + "ahash", + "bincode", + "burn-common", + "burn-derive", + "burn-tensor", + "data-encoding", + "derive-new", + "flate2", + "half", + "hashbrown 0.15.5", + "log", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rand 0.9.4", + "regex", + "rmp-serde", + "serde", + "serde_json", + "spin", + "uuid", +] + +[[package]] +name = "burn-cubecl" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd16308b7b0291c77f2d7acf428bc8254ec3db88a430a26cf3d3b0b63ae2d46" +dependencies = [ + "burn-common", + "burn-cubecl-fusion", + "burn-fusion", + "burn-ir", + "burn-tensor", + "bytemuck", + "cubecl", + "cubecl-quant", + "derive-new", + "futures-lite", + "half", + "hashbrown 0.15.5", + "log", + "num-traits", + "rand 0.9.4", + "serde", + "spin", + "text_placeholder", +] + +[[package]] +name = "burn-cubecl-fusion" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc21cf88201dfbf242cadb638a0cc924010727fc37d6a719f7e10548b339c63a" +dependencies = [ + "burn-common", + "burn-fusion", + "burn-ir", + "burn-tensor", + "cubecl", + "cubecl-quant", + "derive-new", + "half", + "serde", +] + +[[package]] +name = "burn-cuda" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e104dcf07eac70c7b5864b51d792df3360b11b00febb60543b4283bb414bb61" +dependencies = [ + "burn-cubecl", + "burn-tensor", + "bytemuck", + "cubecl", + "derive-new", + "half", + "log", +] + +[[package]] +name = "burn-derive" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcf49261de086b8206de6c8962d2adf23feb476119a18e384f5b2c9af07c0cf" +dependencies = [ + "derive-new", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "burn-fusion" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662bf2679c04be34a0c3f1b11f77f6ff49456af1620d1eca311bc2562bbb56c9" +dependencies = [ + "burn-common", + "burn-ir", + "burn-tensor", + "derive-new", + "half", + "hashbrown 0.15.5", + "log", + "serde", + "spin", +] + +[[package]] +name = "burn-ir" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9161239d5691c4ab6f470f2c65aaec5c0a7c1f0b0da390700bcd59f5a77d1d7b" +dependencies = [ + "burn-tensor", + "hashbrown 0.15.5", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "burn-ndarray" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b78bcf4a3508043342f918e796dc79108b5f3252398403eb73952847e7683374" +dependencies = [ + "atomic_float", + "burn-autodiff", + "burn-common", + "burn-ir", + "burn-tensor", + "const-random", + "derive-new", + "libm", + "macerator", + "matrixmultiply", + "ndarray", + "num-traits", + "paste", + "portable-atomic-util", + "rand 0.9.4", + "spin", +] + +[[package]] +name = "burn-nn" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc7829c87c4dd6c7929b50fd981e7e8d1b77414323da30ce2067a3e8b7ea422b" +dependencies = [ + "burn-core", + "num-traits", +] + +[[package]] +name = "burn-optim" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31758c02e50247f12457fca1905ed8684ac1b1c5292e10cbbfffb9fa0048d4bd" +dependencies = [ + "burn-core", + "derive-new", + "hashbrown 0.15.5", + "log", + "num-traits", + "serde", +] + +[[package]] +name = "burn-rocm" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e1ceb87b6e7349b42d7995477c9a69d0e6c458c64eafa10af3b8b9070f260aa" +dependencies = [ + "burn-cubecl", + "burn-tensor", + "bytemuck", + "cubecl", + "derive-new", + "half", + "log", +] + +[[package]] +name = "burn-router" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45f40403c500b5df380bee47aa0f23032350bdfde5402812d6fcec4d6ff6fbad" +dependencies = [ + "burn-common", + "burn-ir", + "burn-tensor", + "hashbrown 0.15.5", + "log", + "spin", +] + +[[package]] +name = "burn-store" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a2a163486242fcb0c6e2cb89c5a803ab8588673652bb46ecd7af6378d06152f" +dependencies = [ + "burn-core", + "burn-nn", + "burn-tensor", + "byteorder", + "half", + "hashbrown 0.15.5", + "memmap2", + "regex", + "safetensors 0.6.2", +] + +[[package]] +name = "burn-tensor" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8861f7c21d3b07a2b19d028f6eb8903990949708b2ec825559b5200786877c" +dependencies = [ + "burn-common", + "bytemuck", + "colored", + "cubecl", + "cubecl-quant", + "derive-new", + "half", + "hashbrown 0.15.5", + "num-traits", + "rand 0.9.4", + "rand_distr", + "serde", + "serde_bytes", +] + +[[package]] +name = "burn-wgpu" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17aeaa2eadaa4831a64672b99f62ffcdf4874fe4757080633d8a6c4452e2b38" +dependencies = [ + "burn-cubecl", + "burn-tensor", + "cubecl", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cached" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b6f5d101f0f6322c8646a45b7c581a673e476329040d97565815c2461dd0c4" +dependencies = [ + "ahash", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.16.1", + "once_cell", + "parking_lot", + "thiserror 2.0.18", + "web-time", +] + +[[package]] +name = "cached_proc_macro" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ebcf9c75f17a17d55d11afc98e46167d4790a263f428891b8705ab2f793eca3" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + +[[package]] +name = "candle-core" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c15b675b80d994b2eadb20a4bbe434eabeb454eac3ee5e2b4cf6f147ee9be091" +dependencies = [ + "byteorder", + "float8 0.6.1", + "gemm", + "half", + "libm", + "memmap2", + "num-traits", + "num_cpus", + "rand 0.9.4", + "rand_distr", + "rayon", + "safetensors 0.7.0", + "thiserror 2.0.18", + "yoke", + "zip 7.2.0", +] + +[[package]] +name = "caseless" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" +dependencies = [ + "unicode-normalization", +] + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "comrak" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fefab951771fc3beeed0773ce66a4f7b706273fc6c4c95b08dd1615744abcf5" +dependencies = [ + "caseless", + "entities", + "memchr", + "slug", + "typed-arena", + "unicode_categories", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "constcat" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d3e02915a2cea4d74caa8681e2d44b1c3254bdbf17d11d41d587ff858832c" + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "cubecl" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b7c74ecaca9356c9ae79d0ebf1db04f02bd98be09eea61f51d73373dffe758" +dependencies = [ + "cubecl-convolution", + "cubecl-core", + "cubecl-cpu", + "cubecl-cuda", + "cubecl-hip", + "cubecl-matmul", + "cubecl-random", + "cubecl-reduce", + "cubecl-runtime", + "cubecl-std", + "cubecl-wgpu", + "half", +] + +[[package]] +name = "cubecl-common" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4556981155bffc057a8effcd4549b52b51df3e9edec43af6ccae2dd03fc8fbff" +dependencies = [ + "bytemuck", + "cfg-if", + "cfg_aliases", + "derive-new", + "derive_more 2.1.1", + "dirs", + "embassy-futures", + "embassy-time", + "float4", + "float8 0.4.2", + "futures-lite", + "half", + "hashbrown 0.15.5", + "log", + "num-traits", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "rand 0.9.4", + "sanitize-filename", + "serde", + "serde_bytes", + "serde_json", + "spin", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "cubecl-convolution" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c624ec400b7203673bf2db86d7ff30d1384839d497d2dd029c19b1b7371e0d" +dependencies = [ + "bytemuck", + "cubecl-common", + "cubecl-core", + "cubecl-matmul", + "cubecl-random", + "cubecl-reduce", + "cubecl-runtime", + "cubecl-std", + "half", + "pretty_assertions", + "serde", +] + +[[package]] +name = "cubecl-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffc10af538ee74535cda260e581f5a177c243803dd30b698934a515f0114b55" +dependencies = [ + "bitflags", + "bytemuck", + "cubecl-common", + "cubecl-ir", + "cubecl-macros", + "cubecl-runtime", + "derive-new", + "derive_more 2.1.1", + "enumset", + "half", + "hashbrown 0.15.5", + "log", + "num-traits", + "paste", + "serde", + "serde_json", + "variadics_please", +] + +[[package]] +name = "cubecl-cpp" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d630e4d10cdd3af268ac753914ca79b48f01d1e36c5b5039970a817acc925fea" +dependencies = [ + "bytemuck", + "cubecl-common", + "cubecl-core", + "cubecl-opt", + "cubecl-runtime", + "derive-new", + "half", + "itertools 0.14.0", + "log", +] + +[[package]] +name = "cubecl-cpu" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac1693555277d74152afb61a23e30d1f17d72cebd317a648faf50a8e69380f08" +dependencies = [ + "bytemuck", + "cubecl-common", + "cubecl-convolution", + "cubecl-core", + "cubecl-matmul", + "cubecl-opt", + "cubecl-reduce", + "cubecl-runtime", + "cubecl-std", + "derive-new", + "half", + "log", + "serde", + "sysinfo", + "tracel-llvm", + "tracel-llvm-bundler", +] + +[[package]] +name = "cubecl-cuda" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67215fcd552a9e8bc68494a71cf2979f2e2bbcbda60f0695f56f86705b89ed5f" +dependencies = [ + "bytemuck", + "cubecl-common", + "cubecl-core", + "cubecl-cpp", + "cubecl-runtime", + "cudarc", + "derive-new", + "half", + "log", + "serde", +] + +[[package]] +name = "cubecl-hip" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5e2e6a257f702fb2eb6f24e640e228a94695e4a4c73a4c549578cbb02ad4ec5" +dependencies = [ + "bytemuck", + "cubecl-common", + "cubecl-core", + "cubecl-cpp", + "cubecl-hip-sys", + "cubecl-quant", + "cubecl-runtime", + "derive-new", + "half", + "log", + "paste", + "serde", +] + +[[package]] +name = "cubecl-hip-sys" +version = "7.2.5321100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58887c6d217859c2261a868a3a6b66aa8f44ea2f4bfa51d0dbe4dcb0d1f5768d" +dependencies = [ + "libc", + "regex", +] + +[[package]] +name = "cubecl-ir" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf5d3aa7857e6aee1622aef128d6ad8d9289ed57362b4e65d10cc182aafc585f" +dependencies = [ + "cubecl-common", + "cubecl-macros-internal", + "derive-new", + "derive_more 2.1.1", + "float-ord", + "fnv", + "half", + "hashbrown 0.15.5", + "num-traits", + "portable-atomic", + "serde", + "variadics_please", +] + +[[package]] +name = "cubecl-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5200fb619be424749901e3c6e8e66ae71146c8f83636a74f171bd980cba379d7" +dependencies = [ + "cubecl-common", + "darling 0.21.3", + "derive-new", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cubecl-macros-internal" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a1b673f303396fba18df83368aa4eced474584f1bca34852dccc42bd4ff050c" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cubecl-matmul" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1cf0a00609a249d5357c27cafea477f35218579db2ab00582d8d5800be4a5a3" +dependencies = [ + "bytemuck", + "cubecl-common", + "cubecl-core", + "cubecl-random", + "cubecl-reduce", + "cubecl-runtime", + "cubecl-std", + "half", + "pretty_assertions", + "serde", +] + +[[package]] +name = "cubecl-opt" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "870ca4b52f9eebd358c9b360b89cdc9f82bde05682db63f0e90c666b3c85a04d" +dependencies = [ + "cubecl-common", + "cubecl-core", + "cubecl-ir", + "float-ord", + "log", + "num", + "petgraph", + "smallvec", + "stable-vec", + "type-map", +] + +[[package]] +name = "cubecl-quant" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be3e1202c219078d85dbad7f30d1195fe4f9d42cbfad2c94ab0ea1a6d9f01f6" +dependencies = [ + "cubecl-common", + "cubecl-core", + "cubecl-runtime", + "cubecl-std", + "half", + "serde", +] + +[[package]] +name = "cubecl-random" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a293a05caa68663675823bab66205bca094a21a2c0f6686ad9f20b392516179" +dependencies = [ + "cubecl-common", + "cubecl-core", + "cubecl-runtime", + "cubecl-std", + "half", + "num-traits", + "rand 0.9.4", + "serde", +] + +[[package]] +name = "cubecl-reduce" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53306ace81f6262f7ae794370f47e6b5019842b27e8800240e5b039386b3ac3a" +dependencies = [ + "cubecl-core", + "cubecl-runtime", + "cubecl-std", + "half", + "num-traits", + "pretty_assertions", + "rand 0.9.4", + "serde", +] + +[[package]] +name = "cubecl-runtime" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91b823bb5899a6fa8809bf7aa36f93f72ced6de58ab9d6edea2c730b235eeda3" +dependencies = [ + "async-channel", + "bytemuck", + "cfg-if", + "cfg_aliases", + "cubecl-common", + "cubecl-ir", + "derive-new", + "dirs", + "enumset", + "foldhash 0.1.5", + "hashbrown 0.15.5", + "log", + "md5", + "serde", + "serde_json", + "spin", + "thiserror 2.0.18", + "toml", + "variadics_please", + "wasm-bindgen-futures", +] + +[[package]] +name = "cubecl-std" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24536998f9fff84f9a1dd2a90f981d5aa4d15eb35cddec5021c4fcf977d2e75e" +dependencies = [ + "cubecl-common", + "cubecl-core", + "cubecl-runtime", + "foldhash 0.1.5", + "half", + "paste", + "serde", + "spin", + "variadics_please", +] + +[[package]] +name = "cubecl-wgpu" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59a7d737259a784247595e2f0cc5a97d3e50f45cdaefbd4cc7d7fd2126f7a58" +dependencies = [ + "async-channel", + "bytemuck", + "cfg-if", + "cfg_aliases", + "cubecl-common", + "cubecl-core", + "cubecl-runtime", + "derive-new", + "derive_more 2.1.1", + "half", + "hashbrown 0.15.5", + "log", + "sanitize-filename", + "wgpu", +] + +[[package]] +name = "cudarc" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf99ab37ee7072d64d906aa2dada9a3422f1d975cdf8c8055a573bc84897ed8" +dependencies = [ + "libloading", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "derive-new" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl 1.0.0", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl 2.1.1", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "unicode-xid", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dyn-stack" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8" +dependencies = [ + "bytemuck", + "dyn-stack-macros", +] + +[[package]] +name = "dyn-stack-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-time" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f820157f198ada183ad62e0a66f554c610cdcd1a9f27d4b316358103ced7a1f8" +dependencies = [ + "cfg-if", + "critical-section", + "document-features", + "embassy-time-driver", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "futures-util", +] + +[[package]] +name = "embassy-time-driver" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ee71af1b3a0deaa53eaf2d39252f83504c853646e472400b763060389b9fcc9" +dependencies = [ + "document-features", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "entities" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "enumset" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fid-rs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6956a1e60e2d1412b44b4169d44a03dae518f8583d3e10090c912c105e48447" +dependencies = [ + "rayon", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + +[[package]] +name = "float4" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5939bac0ef2ad7c83a53e4fb889c1d81f007b07061d648cd271071984d86f257" + +[[package]] +name = "float8" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4203231de188ebbdfb85c11f3c20ca2b063945710de04e7b59268731e728b462" +dependencies = [ + "half", +] + +[[package]] +name = "float8" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719a903cc23e4a89e87962c2a80fdb45cdaad0983a89bd150bb57b4c8571a7d5" +dependencies = [ + "half", + "num-traits", + "rand 0.9.4", + "rand_distr", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fst" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a" + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gemm" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb" +dependencies = [ + "dyn-stack", + "gemm-c32", + "gemm-c64", + "gemm-common", + "gemm-f16", + "gemm-f32", + "gemm-f64", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c32" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c64" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-common" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e" +dependencies = [ + "bytemuck", + "dyn-stack", + "half", + "libm", + "num-complex", + "num-traits", + "once_cell", + "paste", + "pulp", + "raw-cpuid", + "rayon", + "seq-macro", + "sysctl", +] + +[[package]] +name = "gemm-f16" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e" +dependencies = [ + "dyn-stack", + "gemm-common", + "gemm-f32", + "half", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "rayon", + "seq-macro", +] + +[[package]] +name = "gemm-f32" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-f64" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "bytemuck", + "cfg-if", + "crunchy", + "num-traits", + "rand 0.9.4", + "rand_distr", + "serde", + "zerocopy", +] + +[[package]] +name = "harper-brill" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78176be681477890eea55780e3323e134d4efb937c65c17f7bd9c59e457c86f3" +dependencies = [ + "harper-pos-utils", + "serde_json", +] + +[[package]] +name = "harper-core" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acab57352d953e0ab227d4a0a7a8f990e28053f47b8d633711677a83180f51fb" +dependencies = [ + "ammonia", + "bitflags", + "blanket", + "cached", + "foldhash 0.2.0", + "fst", + "harper-brill", + "harper-thesaurus", + "hashbrown 0.16.1", + "is-macro", + "itertools 0.14.0", + "levenshtein_automata", + "lru", + "ordered-float 5.3.0", + "paste", + "pulldown-cmark", + "regex", + "serde", + "serde_json", + "smallvec", + "strum", + "strum_macros", + "thiserror 2.0.18", + "trie-rs", + "unicode-blocks", + "unicode-script", + "unicode-width", + "zip 8.6.0", +] + +[[package]] +name = "harper-ffi" +version = "0.1.0" +dependencies = [ + "harper-core", + "serde_json", +] + +[[package]] +name = "harper-pos-utils" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a191fccad8ba62230a64a36a33a16635e2f60e08aec5580cc79262fbb8c837dd" +dependencies = [ + "burn", + "burn-ndarray", + "hashbrown 0.16.1", + "is-macro", + "lru", + "rs-conllu", + "serde", + "serde_json", + "strum", + "strum_macros", +] + +[[package]] +name = "harper-thesaurus" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4e193a731f1cdf7496aa8d1900b88cdd1f99a46f725d186feccac5cbe55ad8" +dependencies = [ + "hashbrown 0.16.1", + "indexmap", + "itertools 0.14.0", + "ruzstd", + "zstd", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", + "serde", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-macro" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "levenshtein_automata" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" +dependencies = [ + "fst", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "liblzma" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899" +dependencies = [ + "liblzma-sys", + "num_cpus", +] + +[[package]] +name = "liblzma-sys" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a60851d15cd8c5346eca4ab8babff585be2ae4bc8097c067291d3ffe2add3b6" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "louds-rs" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "936de6c22f08e7135a921f8ada907acd0d88880c4f42b5591f634b9f1dd8e07f" +dependencies = [ + "fid-rs", +] + +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "macerator" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84b0b2dbe8b22f9e96ba12e29964889010117f92e6bd006010887320ae58e2f0" +dependencies = [ + "bytemuck", + "cfg_aliases", + "half", + "macerator-macros", + "moddef", + "num-traits", + "paste", + "rustc_version", +] + +[[package]] +name = "macerator-macros" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5ce85961d618ce9794bdf822bfe96fe9dd341aa5b033b454f7a8d96e79b9b1" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", + "stable_deref_trait", +] + +[[package]] +name = "metal" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moddef" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0b3262dc837d2513fe2ef31ff8461352ef932dcca31ba0c0abe33547cf6b9b" + +[[package]] +name = "naga" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916cbc7cb27db60be930a4e2da243cf4bc39569195f22fd8ee419cd31d5b662c" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.15.5", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", + "rand 0.8.6", + "serde", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.6", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "pulp" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e205bb30d5b916c55e584c22201771bcf2bad9aabd5d4127f38387140c38632" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40e24eee682d89fb193496edf918a7f407d30175b2e785fe057e4392dfd182e0" + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.2", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash 2.1.2", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core 0.6.4", + "serde", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "serde", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand 0.9.4", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rs-conllu" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6de5aecf17f8fff1b35d59a12e2b8c908cad4d67208805166483655554f9169" +dependencies = [ + "clap", + "derive_more 1.0.0", + "thiserror 1.0.69", + "walkdir", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ruzstd" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safetensors" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172dd94c5a87b5c79f945c863da53b2ebc7ccef4eca24ac63cca66a41aab2178" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "safetensors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5" +dependencies = [ + "hashbrown 0.16.1", + "serde", + "serde_json", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sanitize-filename" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" +dependencies = [ + "regex", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +dependencies = [ + "lock_api", + "portable-atomic", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags", +] + +[[package]] +name = "stable-vec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dac7bc0f7d0d44329b200020effbc25a534d89fa142af95e3ddf76113412a5e" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sysctl" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" +dependencies = [ + "bitflags", + "byteorder", + "enum-as-inner", + "libc", + "thiserror 1.0.69", + "walkdir", +] + +[[package]] +name = "sysinfo" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "text_placeholder" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5008f74a09742486ef0047596cf35df2b914e2a8dca5727fcb6ba6842a766b" +dependencies = [ + "hashbrown 0.13.2", + "serde", + "serde_json", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracel-llvm" +version = "20.1.4-7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "982535db9eb1a30ac0f2c50239a0eec3e5cf50993a88e92b04747bd2f4d365b2" +dependencies = [ + "tracel-mlir-rs", + "tracel-mlir-sys", +] + +[[package]] +name = "tracel-llvm-bundler" +version = "20.1.4-7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c75b8e477cb8d49d907afab029ca74d48459f5b88c27bdb4c6cd6acb5e61977" +dependencies = [ + "anyhow", + "bytes", + "constcat", + "dirs", + "liblzma", + "regex", + "reqwest", + "serde", + "serde_json", + "sha2", + "tar", + "walkdir", +] + +[[package]] +name = "tracel-mlir-rs" +version = "20.1.4-7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a478a35efd68d0ba73f747adfb7923b121c64e7f5be9cd8364ca1dcb772d5c" +dependencies = [ + "tracel-mlir-rs-macros", + "tracel-mlir-sys", +] + +[[package]] +name = "tracel-mlir-rs-macros" +version = "20.1.4-7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a94f36868c3b10b1825945223d99d106c73f4d249f063caa4651deeb9379344" +dependencies = [ + "comrak", + "convert_case 0.8.0", + "proc-macro2", + "quote", + "regex", + "syn", + "tracel-llvm-bundler", + "tracel-tblgen-rs", + "unindent", +] + +[[package]] +name = "tracel-mlir-sys" +version = "20.1.4-7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02f26d31af0c225a6d2e3d65d012fd6de848c9fc776897b152ee83b7d1bd15c4" +dependencies = [ + "tracel-llvm-bundler", +] + +[[package]] +name = "tracel-tblgen-rs" +version = "20.1.4-7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d2581070380418ccc33b500f3739e4d4869421fdb477fcea51ff97c6253a52" +dependencies = [ + "bindgen", + "cc", + "paste", + "thiserror 2.0.18", + "tracel-llvm-bundler", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "trie-rs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f88f4b0a1ebd6c3d16be3e45eb0e8089372ccadd88849b7ca162ba64b5e6f6" +dependencies = [ + "louds-rs", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.2", +] + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-blocks" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b12e05d9e06373163a9bb6bb8c263c261b396643a99445fe6b9811fd376581b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" + +[[package]] +name = "variadics_please" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "wgpu" +version = "26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70b6ff82bbf6e9206828e1a3178e851f8c20f1c9028e74dd3a8090741ccd5798" +dependencies = [ + "arrayvec", + "bitflags", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.15.5", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f62f1053bd28c2268f42916f31588f81f64796e2ff91b81293515017ca8bd9" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags", + "cfg_aliases", + "document-features", + "hashbrown 0.15.5", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18ae5fbde6a4cbebae38358aa73fcd6e0f15c6144b67ef5dc91ded0db125dbdf" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7670e390f416006f746b4600fdd9136455e3627f5bd763abf9a65daa216dd2d" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "720a5cb9d12b3d337c15ff0e24d3e97ed11490ff3f7506e7f3d98c68fa5d6f14" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "26.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d0e67224cc7305b3b4eb2cc57ca4c4c3afc665c1d1bee162ea806e19c47bdd" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.15.5", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys", + "objc", + "ordered-float 4.6.0", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.18", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca7a8d8af57c18f57d393601a1fb159ace8b2328f1b6b5f80893f7d672c9ae2" +dependencies = [ + "bitflags", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.18", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "7.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" +dependencies = [ + "crc32fast", + "indexmap", + "memchr", + "typed-path", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/harper-ffi/Cargo.toml b/harper-ffi/Cargo.toml new file mode 100644 index 0000000..e528ada --- /dev/null +++ b/harper-ffi/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "harper-ffi" +version = "0.1.0" +edition = "2021" + +[lib] +# Static library exposing a C ABI for linking into the Swift app. +name = "harper_ffi" +crate-type = ["staticlib"] + +[dependencies] +# harper-core 2.5.0 — the grammar checker from the Automattic Harper project. +# Verified against https://docs.rs/harper-core/2.5.0/ +harper-core = "2.5.0" +# Used to build the output JSON with correct string escaping. +serde_json = "1" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 diff --git a/harper-ffi/README.md b/harper-ffi/README.md index 2d4153f..6fd2d49 100644 --- a/harper-ffi/README.md +++ b/harper-ffi/README.md @@ -5,7 +5,7 @@ A thin Rust static library that exposes the **Harper** grammar core integration (spec §1, §2, §8). The Swift side talks to this through the `GrammarEngine` protocol; today -[`HarperEngine.swift`](../Sources/GrammaGem/Grammar/HarperEngine.swift) ships a +[`HarperEngine.swift`](../Sources/GrammarGem/Grammar/HarperEngine.swift) ships a small pure-Swift rule stub so the system loop + tests run with zero native deps. ## Planned shape diff --git a/harper-ffi/build.sh b/harper-ffi/build.sh new file mode 100755 index 0000000..5447085 --- /dev/null +++ b/harper-ffi/build.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Build the UNIVERSAL (Apple Silicon + Intel) Harper static library that the +# Swift app links against: harper-ffi/lib/libharper_ffi.a +# +# Requires the Rust toolchain (rustup) with both macOS targets: +# rustup target add aarch64-apple-darwin x86_64-apple-darwin +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT" + +# Make cargo available even in non-login shells. +[ -f "$HOME/.cargo/env" ] && source "$HOME/.cargo/env" + +echo "==> cargo build --release (aarch64 + x86_64)" +cargo build --release --target aarch64-apple-darwin +cargo build --release --target x86_64-apple-darwin + +echo "==> lipo -> lib/libharper_ffi.a (universal)" +mkdir -p lib +lipo -create -output lib/libharper_ffi.a \ + target/aarch64-apple-darwin/release/libharper_ffi.a \ + target/x86_64-apple-darwin/release/libharper_ffi.a + +lipo -info lib/libharper_ffi.a +echo "==> done" diff --git a/harper-ffi/include/harper.h b/harper-ffi/include/harper.h new file mode 100644 index 0000000..3518fb6 --- /dev/null +++ b/harper-ffi/include/harper.h @@ -0,0 +1,56 @@ +/* + * harper.h — C ABI for the harper-ffi static library (harper-core 2.5.0). + * + * Link against libharper_ffi.a. All strings are UTF-8. + */ +#ifndef HARPER_FFI_H +#define HARPER_FFI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Check `text` for grammar/spelling issues using harper-core. + * + * Returns a malloc'd, NUL-terminated UTF-8 JSON C string that the caller MUST + * free with harper_free(). + * + * The JSON is an array of objects: + * [ + * { + * "start": , + * "len": , + * "replacement": "", + * "message": "", + * "kind": "" + * }, + * ... + * ] + * + * Notes on the contract: + * - "start"/"len" are CHAR (Unicode scalar) offsets, matching harper's native + * char indexing. They are NOT byte offsets and NOT UTF-16 code-unit offsets. + * "len" = span.end - span.start (end-exclusive). + * - "replacement" is the FIRST ReplaceWith suggestion's text. If the first + * actionable suggestion is a removal, "replacement" is "" (empty string). + * - Lints with NO actionable suggestion are SKIPPED. + * - "kind" is one of: "spelling", "grammar", "punctuation", "repetition", + * "capitalization", "phrasing". Best-effort; defaults to "grammar". + * + * On a null `text`, invalid UTF-8, or any internal error/panic, returns "[]". + * Never returns NULL on a normal return path. Thread-safe. + */ +char *harper_check(const char *text); + +/* + * Free a string previously returned by harper_check(). + * Passing NULL is a no-op. Do not free the same pointer twice. + */ +void harper_free(char *ptr); + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* HARPER_FFI_H */ diff --git a/harper-ffi/src/lib.rs b/harper-ffi/src/lib.rs new file mode 100644 index 0000000..cd6b330 --- /dev/null +++ b/harper-ffi/src/lib.rs @@ -0,0 +1,170 @@ +//! C-ABI FFI bridge exposing harper-core 2.5.0's grammar checker to Swift. +//! +//! Public surface: +//! char* harper_check(const char* text); // malloc'd UTF-8 JSON, free with harper_free +//! void harper_free(char* ptr); +//! +//! Every API call below was written against docs.rs for harper-core 2.5.0. + +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::{Arc, OnceLock}; + +use harper_core::{Dialect, Document}; +use harper_core::linting::{LintGroup, LintKind, Linter, Suggestion}; +use harper_core::spell::FstDictionary; + +/// Empty JSON array used for null/invalid input and on any panic. +const EMPTY_JSON: &str = "[]"; + +/// The curated FST dictionary is the expensive artifact to build, so build it +/// exactly once and share it across calls/threads via `Arc`. `FstDictionary` +/// is immutable and `Arc` is `Send + Sync`, so this is safe to +/// read from any thread. +/// +/// We intentionally do NOT cache the `LintGroup` itself: `Linter::lint` takes +/// `&mut self`, which would require a `Mutex` and serialize all callers. The +/// `LintGroup` is cheap to reconstruct relative to the dictionary, so we build +/// a fresh one per call from the shared dictionary instead. +fn curated_dictionary() -> Arc { + static DICT: OnceLock> = OnceLock::new(); + DICT.get_or_init(FstDictionary::curated).clone() +} + +/// Map a harper `LintKind` to the lowercase string contract: +/// one of spelling, grammar, punctuation, repetition, capitalization, phrasing. +/// Default is "grammar" for anything not specifically categorized. +fn kind_to_str(kind: LintKind) -> &'static str { + match kind { + LintKind::Spelling | LintKind::Typo => "spelling", + LintKind::Punctuation | LintKind::Formatting => "punctuation", + LintKind::Repetition => "repetition", + LintKind::Capitalization => "capitalization", + // "phrasing"-flavored categories: word choice / style / readability / usage. + LintKind::WordChoice + | LintKind::Style + | LintKind::Readability + | LintKind::Usage + | LintKind::Redundancy + | LintKind::Enhancement + | LintKind::Eggcorn + | LintKind::Malapropism + | LintKind::Nonstandard + | LintKind::Regionalism => "phrasing", + // Grammar-ish / structural categories. + LintKind::Grammar + | LintKind::Agreement + | LintKind::BoundaryError + | LintKind::Miscellaneous => "grammar", + // LintKind may gain variants; default safely. + _ => "grammar", + } +} + +/// Run harper over `text` and produce the JSON contract string. +fn check_to_json(text: &str) -> String { + let dictionary = curated_dictionary(); + + // Build the document with the built-in PlainEnglish parser + curated dictionary. + let document = Document::new_plain_english(text, &dictionary); + + // Fresh curated lint group from the shared dictionary; American dialect. + let mut group = LintGroup::new_curated(dictionary.clone(), Dialect::American); + + // Linter::lint(&mut self, &Document) -> Vec + let lints = group.lint(&document); + + let mut out: Vec = Vec::with_capacity(lints.len()); + for lint in lints { + // span is Span: start/end are CHAR (Unicode scalar) offsets, end-exclusive. + let start = lint.span.start; + let len = lint.span.end.saturating_sub(lint.span.start); + + // Pick the first ReplaceWith suggestion as "replacement". + // If the first actionable suggestion is a Remove, replacement = "". + // InsertAfter is treated as non-replacement here. Lints with no + // actionable suggestion are skipped. + let mut replacement: Option = None; + for suggestion in &lint.suggestions { + match suggestion { + Suggestion::ReplaceWith(chars) => { + replacement = Some(chars.iter().collect()); + break; + } + Suggestion::Remove => { + replacement = Some(String::new()); + break; + } + Suggestion::InsertAfter(_) => {} + } + } + + let replacement = match replacement { + Some(r) => r, + None => continue, + }; + + out.push(serde_json::json!({ + "start": start, + "len": len, + "replacement": replacement, + "message": lint.message, + "kind": kind_to_str(lint.lint_kind), + })); + } + + serde_json::to_string(&out).unwrap_or_else(|_| EMPTY_JSON.to_string()) +} + +/// Allocate a C string for return to the caller. The caller owns it and must +/// free it with `harper_free`. +fn into_c_string(s: String) -> *mut c_char { + match CString::new(s) { + Ok(cstr) => cstr.into_raw(), + Err(_) => CString::new(EMPTY_JSON).unwrap().into_raw(), + } +} + +/// Check `text` for grammar/spelling issues. +/// +/// Returns a malloc'd, NUL-terminated UTF-8 JSON C string the caller MUST free +/// with `harper_free`. On null/invalid-UTF-8 input or on any internal panic, +/// returns the JSON `"[]"`. Never returns null on a normal return path. +/// +/// # Safety +/// `text` must be a valid NUL-terminated C string pointer or null. +#[no_mangle] +pub extern "C" fn harper_check(text: *const c_char) -> *mut c_char { + let result = catch_unwind(AssertUnwindSafe(|| { + if text.is_null() { + return EMPTY_JSON.to_string(); + } + // SAFETY: caller guarantees a valid NUL-terminated C string (non-null checked). + let c_str = unsafe { CStr::from_ptr(text) }; + match c_str.to_str() { + Ok(s) => check_to_json(s), + Err(_) => EMPTY_JSON.to_string(), + } + })); + + match result { + Ok(json) => into_c_string(json), + Err(_) => into_c_string(EMPTY_JSON.to_string()), + } +} + +/// Free a string previously returned by `harper_check`. +/// +/// # Safety +/// `ptr` must be a pointer returned by `harper_check` (or null), freed once. +#[no_mangle] +pub extern "C" fn harper_free(ptr: *mut c_char) { + if ptr.is_null() { + return; + } + // SAFETY: ptr came from CString::into_raw in this module; reclaim and drop it. + unsafe { + drop(CString::from_raw(ptr)); + } +} diff --git a/scripts/build-metallib.sh b/scripts/build-metallib.sh new file mode 100755 index 0000000..755a7b4 --- /dev/null +++ b/scripts/build-metallib.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Regenerate AppSupport/mlx.metallib — the MLX on-device LLM's compiled Metal +# kernels. +# +# `swift build` (SwiftPM CLI) does NOT compile mlx-swift's .metal sources into a +# metallib, but Xcode's build system does. So we build the package once via +# xcodebuild, then vendor the resulting metallib (shipped colocated with the app +# executable; see scripts/build.sh). Run this when bumping the mlx-swift version. +# +# Requires the Metal Toolchain: xcodebuild -downloadComponent MetalToolchain +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +DD=".xcode-build" + +echo "==> xcodebuild (compiles mlx-swift metal kernels — a few minutes)" +xcodebuild build -scheme GrammarGem -configuration Release \ + -destination 'platform=macOS,arch=arm64' \ + -derivedDataPath "$DD" -skipMacroValidation >/dev/null + +SRC="$DD/Build/Products/Release/mlx-swift_Cmlx.bundle/Contents/Resources/default.metallib" +[ -f "$SRC" ] || { echo "ERROR: metallib not produced at $SRC" >&2; exit 1; } +cp "$SRC" AppSupport/mlx.metallib +echo "==> vendored AppSupport/mlx.metallib ($(du -h AppSupport/mlx.metallib | cut -f1))" diff --git a/scripts/build.sh b/scripts/build.sh index 86c8fc3..1d66509 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Build a runnable, UNIVERSAL (Apple Silicon + Intel) GrammaGem.app bundle. +# Build a runnable GrammarGem.app bundle (arm64 / Apple Silicon — the MLX +# on-device LLM runtime is Metal-only, so Intel is not supported). # Usage: ./scripts/build.sh (run from the mac/ directory) set -euo pipefail @@ -7,21 +8,52 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" CONFIG="release" -APP="GrammaGem.app" +APP="GrammarGem.app" DIST="dist" -echo "==> Compiling universal (arm64 + x86_64, ${CONFIG})" -swift build -c "${CONFIG}" --arch arm64 --arch x86_64 -BIN="$(swift build -c "${CONFIG}" --arch arm64 --arch x86_64 --show-bin-path)" +echo "==> Building Harper static lib (universal)" +chmod +x harper-ffi/build.sh +./harper-ffi/build.sh + +echo "==> Compiling (arm64 / Apple Silicon, ${CONFIG})" +# arm64-only: the MLX on-device LLM runtime is Apple-Silicon / Metal only. +swift build -c "${CONFIG}" --arch arm64 +BIN="$(swift build -c "${CONFIG}" --arch arm64 --show-bin-path)" echo "==> Assembling ${APP}" rm -rf "${DIST}/${APP}" mkdir -p "${DIST}/${APP}/Contents/MacOS" "${DIST}/${APP}/Contents/Resources" -cp "${BIN}/GrammaGem" "${DIST}/${APP}/Contents/MacOS/GrammaGem" +cp "${BIN}/GrammarGem" "${DIST}/${APP}/Contents/MacOS/GrammarGem" cp "AppSupport/Info.plist" "${DIST}/${APP}/Contents/Info.plist" cp "AppSupport/AppIcon.icns" "${DIST}/${APP}/Contents/Resources/AppIcon.icns" +# SwiftPM resource bundles (tokenizer data, etc.) go in Contents/Resources so +# Bundle.module resolves via the main bundle and the signature stays valid. +shopt -s nullglob +for b in "${BIN}"/*.bundle; do + cp -R "$b" "${DIST}/${APP}/Contents/Resources/" +done +shopt -u nullglob + +# MLX Metal kernels: `swift build` (CLI) does NOT compile mlx-swift's .metal +# sources, so we ship a prebuilt metallib in the exact bundle mlx looks for — +# Contents/Resources/mlx-swift_Cmlx.bundle/Contents/Resources/default.metallib — +# which mlx finds via the main bundle's resources and which codesigns cleanly. +# Regenerate AppSupport/mlx.metallib with: ./scripts/build-metallib.sh +MLX_RES="${DIST}/${APP}/Contents/Resources/mlx-swift_Cmlx.bundle/Contents/Resources" +mkdir -p "${MLX_RES}" +cp "AppSupport/mlx.metallib" "${MLX_RES}/default.metallib" + +# Embed Sparkle.framework (auto-update) and add an rpath so the executable can +# resolve it at @executable_path/../Frameworks. (Signed inside-out by sign-app.sh.) +echo "==> Embedding Sparkle.framework" +SPARKLE_FW="$(find .build -path '*Sparkle.xcframework/macos-arm64*/Sparkle.framework' -type d | head -1)" +mkdir -p "${DIST}/${APP}/Contents/Frameworks" +cp -R "${SPARKLE_FW}" "${DIST}/${APP}/Contents/Frameworks/" +install_name_tool -add_rpath "@executable_path/../Frameworks" \ + "${DIST}/${APP}/Contents/MacOS/GrammarGem" 2>/dev/null || true + echo "==> Built ${DIST}/${APP}" -lipo -info "${DIST}/${APP}/Contents/MacOS/GrammaGem" +lipo -info "${DIST}/${APP}/Contents/MacOS/GrammarGem" echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)" diff --git a/scripts/make-icon.swift b/scripts/make-icon.swift index 81c0e3c..3226134 100644 --- a/scripts/make-icon.swift +++ b/scripts/make-icon.swift @@ -1,5 +1,5 @@ #!/usr/bin/env swift -// Generates AppIcon.iconset (PNGs) for GrammaGem — a gold pen-nib on the ink +// Generates AppIcon.iconset (PNGs) for GrammarGem — a gold pen-nib on the ink // brand square. Run from mac/: swift scripts/make-icon.swift // Then: iconutil -c icns AppIcon.iconset -o AppSupport/AppIcon.icns import AppKit diff --git a/scripts/release.sh b/scripts/release.sh index db7a335..6711020 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,32 +1,71 @@ #!/usr/bin/env bash -# Package a signed build for distribution and update the Sparkle appcast hosted -# on GitHub Releases (zero hosting cost). Run after sign-notarize.sh. +# Cut a GrammarGem release and publish the Sparkle appcast (hosted on grammargem.com). # -# This is a documented scaffold — wire it to your Sparkle EdDSA key + repo. +# ./scripts/release.sh e.g. ./scripts/release.sh 0.2.0 +# +# Steps: stamp version -> build -> sign inside-out (incl. Sparkle) -> notarize + +# staple -> zip -> generate the EdDSA-signed appcast.xml. Then upload the .zip and +# appcast.xml to grammargem.com so every installed app shows "Update available". +# +# Notarization needs ONE of: +# NOTARY_PROFILE (xcrun notarytool store-credentials) +# APPLE_ID + TEAM_ID + NOTARY_PASSWORD (Apple ID + app-specific password) +# Set SKIP_NOTARIZE=1 for a dry run (un-notarized build; do not ship). set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -APP="$ROOT/dist/GrammaGem.app" -VERSION="${1:?Usage: release.sh e.g. 0.1.0}" -DMG="$ROOT/dist/GrammaGem-$VERSION.dmg" +cd "$ROOT" -echo "▸ Creating DMG for $VERSION…" -# hdiutil create -volname "GrammaGem" -srcfolder "$APP" -ov -format UDZO "$DMG" # TODO: enable -echo " (DMG creation stubbed — uncomment hdiutil line)" +VERSION="${1:?usage: release.sh e.g. release.sh 0.2.0}" +APP="dist/GrammarGem.app" +RELDIR="dist/releases" +ZIP="${RELDIR}/GrammarGem-${VERSION}.zip" +DL_PREFIX="${DL_PREFIX:-https://www.grammargem.com/releases/}" +PB=/usr/libexec/PlistBuddy -cat <<'NOTE' -▸ Sparkle appcast (TODO): - 1. Sign the update: ./bin/sign_update dist/GrammaGem-.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. +# 1. Stamp the marketing version + bump the monotonic build number. +CUR_BUILD="$($PB -c 'Print :CFBundleVersion' AppSupport/Info.plist)" +NEW_BUILD=$((CUR_BUILD + 1)) +$PB -c "Set :CFBundleShortVersionString ${VERSION}" AppSupport/Info.plist +$PB -c "Set :CFBundleVersion ${NEW_BUILD}" AppSupport/Info.plist +echo "==> Releasing ${VERSION} (build ${NEW_BUILD})" -▸ Distribution touchpoints (all $0 marginal cost): - - Model weights: Hugging Face CDN - - Updates: GitHub Releases + Sparkle - - Licensing: Lemon Squeezy License API (% per sale only) -NOTE +# 2. Build + sign (inside-out so the embedded Sparkle.framework is valid). +./scripts/build.sh +./scripts/sign-app.sh "$APP" -echo "✓ Release steps printed for $VERSION" +# 3. Zip. +mkdir -p "$RELDIR" +rm -f "$ZIP" +ditto -c -k --keepParent "$APP" "$ZIP" + +# 4. Notarize + staple (the ticket lets Sparkle install it without Gatekeeper prompts). +if [ "${SKIP_NOTARIZE:-0}" != "1" ]; then + echo "==> Notarizing" + if [ -n "${NOTARY_PROFILE:-}" ]; then + xcrun notarytool submit "$ZIP" --keychain-profile "$NOTARY_PROFILE" --wait + elif [ -n "${APPLE_ID:-}" ] && [ -n "${TEAM_ID:-}" ] && [ -n "${NOTARY_PASSWORD:-}" ]; then + xcrun notarytool submit "$ZIP" \ + --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$NOTARY_PASSWORD" --wait + else + echo "ERROR: set NOTARY_PROFILE or APPLE_ID+TEAM_ID+NOTARY_PASSWORD (or SKIP_NOTARIZE=1)." >&2 + exit 1 + fi + xcrun stapler staple "$APP" + rm -f "$ZIP"; ditto -c -k --keepParent "$APP" "$ZIP" # re-zip the stapled app +fi + +# 5. Generate the EdDSA-signed appcast (keeps every zip in RELDIR in its history). +GA="$(find .build/artifacts -name generate_appcast -type f | head -1)" +[ -n "$GA" ] || { echo "ERROR: generate_appcast not found (run 'swift package resolve')." >&2; exit 1; } +"$GA" --download-url-prefix "$DL_PREFIX" "$RELDIR" + +echo +echo "✓ Release ${VERSION} built. Upload these to grammargem.com:" +echo " ${ZIP}" +echo " -> ${DL_PREFIX}GrammarGem-${VERSION}.zip" +echo " ${RELDIR}/appcast.xml" +echo " -> https://www.grammargem.com/appcast.xml (matches Info.plist SUFeedURL)" +echo +echo " Once uploaded, installed apps show an 'Update available' button within a day" +echo " (or immediately via menu bar -> Check for Updates...)." diff --git a/scripts/sign-app.sh b/scripts/sign-app.sh new file mode 100755 index 0000000..743b882 --- /dev/null +++ b/scripts/sign-app.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Code-sign GrammarGem.app with Developer ID + Hardened Runtime, signing the +# embedded Sparkle.framework's nested helpers INSIDE-OUT first (required for a +# valid, notarizable signature). +# +# Usage: ./scripts/sign-app.sh [path/to/GrammarGem.app] +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +DEV_ID="${DEV_ID:-Developer ID Application: Marc Schultheiss (D5KT5B9Z9M)}" +ENTITLEMENTS="AppSupport/GrammarGem.entitlements" +APP="${1:-dist/GrammarGem.app}" + +sign() { codesign --force --timestamp --options runtime --sign "$DEV_ID" "$@"; } + +FW="${APP}/Contents/Frameworks/Sparkle.framework" +if [ -d "$FW" ]; then + echo "==> Signing Sparkle.framework (inside-out)" + V="${FW}/Versions/B" + for xpc in "$V"/XPCServices/*.xpc; do [ -e "$xpc" ] && sign "$xpc"; done + [ -e "$V/Updater.app/Contents/MacOS/Autoupdate" ] && sign "$V/Updater.app/Contents/MacOS/Autoupdate" || true + [ -e "$V/Updater.app" ] && sign "$V/Updater.app" + [ -e "$V/Autoupdate" ] && sign "$V/Autoupdate" + sign "$FW" +fi + +echo "==> Signing ${APP}" +codesign --force --timestamp --options runtime \ + --entitlements "$ENTITLEMENTS" --sign "$DEV_ID" "$APP" + +echo "==> Verifying" +codesign --verify --strict --verbose=2 "$APP" +echo "✓ signed: $APP" diff --git a/scripts/sign-notarize.sh b/scripts/sign-notarize.sh index be65729..38787db 100755 --- a/scripts/sign-notarize.sh +++ b/scripts/sign-notarize.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Build (universal), codesign (Hardened Runtime + Developer ID), notarize, and -# staple GrammaGem.app. Requires the Apple Developer Program ($99/yr) — a FIXED +# staple GrammarGem.app. Requires the Apple Developer Program ($99/yr) — a FIXED # cost, not per-user. # # Credentials are read from the environment or a stored keychain profile — never @@ -15,19 +15,15 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" DEV_ID="${DEV_ID:-Developer ID Application: Marc Schultheiss (D5KT5B9Z9M)}" -APP="dist/GrammaGem.app" -ZIP="dist/GrammaGem.zip" -ENTITLEMENTS="AppSupport/GrammaGem.entitlements" +APP="dist/GrammarGem.app" +ZIP="dist/GrammarGem.zip" +ENTITLEMENTS="AppSupport/GrammarGem.entitlements" echo "==> Building universal bundle" ./scripts/build.sh >/dev/null -echo "==> Codesigning (Hardened Runtime)" -codesign --force --timestamp --options runtime \ - --entitlements "$ENTITLEMENTS" \ - --sign "$DEV_ID" \ - "$APP" -codesign --verify --strict --verbose=2 "$APP" +echo "==> Codesigning (Hardened Runtime, inside-out incl. Sparkle)" +DEV_ID="$DEV_ID" ./scripts/sign-app.sh "$APP" echo "==> Zipping" ditto -c -k --keepParent "$APP" "$ZIP"