mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
f
This commit is contained in:
parent
3d749e8139
commit
e5b0977abf
@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!--
|
<!--
|
||||||
Info.plist for the packaged GrammaGem.app bundle.
|
Info.plist for the packaged GrammarGem.app bundle.
|
||||||
|
|
||||||
`swift build` produces a bare executable; to ship a real menu-bar app you wrap
|
`swift build` produces a bare executable; to ship a real menu-bar app you wrap
|
||||||
it in a .app bundle with this Info.plist (see scripts/build.sh) or, more
|
it in a .app bundle with this Info.plist (see scripts/build.sh) or, more
|
||||||
@ -18,13 +18,13 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>GrammaGem</string>
|
<string>GrammarGem</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>GrammaGem</string>
|
<string>GrammarGem</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>com.grammagem.app</string>
|
<string>com.grammargem.app</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>GrammaGem</string>
|
<string>GrammarGem</string>
|
||||||
<key>CFBundleIconFile</key>
|
<key>CFBundleIconFile</key>
|
||||||
<string>AppIcon</string>
|
<string>AppIcon</string>
|
||||||
<key>CFBundleIconName</key>
|
<key>CFBundleIconName</key>
|
||||||
@ -44,11 +44,11 @@
|
|||||||
<key>NSPrincipalClass</key>
|
<key>NSPrincipalClass</key>
|
||||||
<string>NSApplication</string>
|
<string>NSApplication</string>
|
||||||
|
|
||||||
<!-- Sparkle auto-update. Appcast is served from grammagem.app and updates are
|
<!-- Sparkle auto-update. Appcast is served from grammargem.com and updates are
|
||||||
EdDSA-signed with the key whose public half is below (private key lives in
|
EdDSA-signed with the key whose public half is below (private key lives in
|
||||||
the release machine's keychain; sign with scripts/release.sh). -->
|
the release machine's keychain; sign with scripts/release.sh). -->
|
||||||
<key>SUFeedURL</key>
|
<key>SUFeedURL</key>
|
||||||
<string>https://grammagem.app/appcast.xml</string>
|
<string>https://www.grammargem.com/appcast.xml</string>
|
||||||
<key>SUPublicEDKey</key>
|
<key>SUPublicEDKey</key>
|
||||||
<string>IwYch+DRgNk3gmCwe86z+SIc4sRdyDf4Z6xGpS+JXDc=</string>
|
<string>IwYch+DRgNk3gmCwe86z+SIc4sRdyDf4Z6xGpS+JXDc=</string>
|
||||||
<key>SUEnableAutomaticChecks</key>
|
<key>SUEnableAutomaticChecks</key>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
import PackageDescription
|
import PackageDescription
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// GrammaGem — native macOS menu-bar writing assistant.
|
// GrammarGem — native macOS menu-bar writing assistant.
|
||||||
//
|
//
|
||||||
// Layer-1 grammar is the real **Harper** core (Apache-2.0), embedded as a Rust
|
// 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
|
// C-FFI static library (see `harper-ffi/`). Build the lib first with
|
||||||
@ -18,41 +18,41 @@ let packageRoot = URL(fileURLWithPath: #filePath).deletingLastPathComponent().pa
|
|||||||
let harperLibDir = packageRoot + "/harper-ffi/lib"
|
let harperLibDir = packageRoot + "/harper-ffi/lib"
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "GrammaGem",
|
name: "GrammarGem",
|
||||||
platforms: [
|
platforms: [
|
||||||
.macOS(.v14) // Apple Silicon, macOS 14+ (per the product spec)
|
.macOS(.v14) // Apple Silicon, macOS 14+ (per the product spec)
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
.executable(name: "GrammaGem", targets: ["GrammaGem"])
|
.executable(name: "GrammarGem", targets: ["GrammarGem"])
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
// On-device LLM runtime. Pinned to an exact tag (the LLM libraries still
|
// 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").
|
// 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"),
|
.package(url: "https://github.com/ml-explore/mlx-swift-examples.git", exact: "2.25.9"),
|
||||||
// Secure auto-updates (EdDSA-signed appcast served from grammagem.app).
|
// Secure auto-updates (EdDSA-signed appcast served from grammargem.com).
|
||||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"),
|
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
// C shim exposing libharper_ffi's C ABI (harper-ffi/include/harper.h) to Swift.
|
// C shim exposing libharper_ffi's C ABI (harper-ffi/include/harper.h) to Swift.
|
||||||
.target(name: "CHarper", path: "Sources/CHarper"),
|
.target(name: "CHarper", path: "Sources/CHarper"),
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "GrammaGem",
|
name: "GrammarGem",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"CHarper",
|
"CHarper",
|
||||||
.product(name: "MLXLLM", package: "mlx-swift-examples"),
|
.product(name: "MLXLLM", package: "mlx-swift-examples"),
|
||||||
.product(name: "MLXLMCommon", package: "mlx-swift-examples"),
|
.product(name: "MLXLMCommon", package: "mlx-swift-examples"),
|
||||||
.product(name: "Sparkle", package: "Sparkle"),
|
.product(name: "Sparkle", package: "Sparkle"),
|
||||||
],
|
],
|
||||||
path: "Sources/GrammaGem",
|
path: "Sources/GrammarGem",
|
||||||
linkerSettings: [
|
linkerSettings: [
|
||||||
// Link the prebuilt Harper static library (arm64 slice).
|
// Link the prebuilt Harper static library (arm64 slice).
|
||||||
.unsafeFlags(["-L\(harperLibDir)", "-lharper_ffi"])
|
.unsafeFlags(["-L\(harperLibDir)", "-lharper_ffi"])
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "GrammaGemTests",
|
name: "GrammarGemTests",
|
||||||
dependencies: ["GrammaGem"],
|
dependencies: ["GrammarGem"],
|
||||||
path: "Tests/GrammaGemTests"
|
path: "Tests/GrammarGemTests"
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
18
README.md
18
README.md
@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
# ✒️ GrammaGem
|
# ✒️ GrammarGem
|
||||||
|
|
||||||
### Write it once. Get it right.
|
### Write it once. Get it right.
|
||||||
|
|
||||||
@ -12,7 +12,7 @@ Fix grammar, sharpen tone, and rewrite anything — in every app — for one pay
|
|||||||

|

|
||||||

|

|
||||||
|
|
||||||
[**⬇️ Download GrammaGem**](https://grammagem.app/#pricing) · [Docs](https://grammagem.app/docs) · [How it works](https://grammagem.app)
|
[**⬇️ Download GrammarGem**](https://www.grammargem.com/#pricing) · [Docs](https://www.grammargem.com/docs) · [How it works](https://www.grammargem.com)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -20,7 +20,7 @@ Fix grammar, sharpen tone, and rewrite anything — in every app — for one pay
|
|||||||
|
|
||||||
## Stop renting your grammar checker.
|
## 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**.
|
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
|
## Why people switch
|
||||||
|
|
||||||
| | Grammarly | **GrammaGem** |
|
| | Grammarly | **GrammarGem** |
|
||||||
|---|:---:|:---:|
|
|---|:---:|:---:|
|
||||||
| **Price** | ~$144 / year, forever | **Pay once** |
|
| **Price** | ~$144 / year, forever | **Pay once** |
|
||||||
| **Your words** | Sent to the cloud | **Never leave your Mac** |
|
| **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.
|
- 🔒 **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.
|
- ⌨️ **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.
|
- ✨ **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.
|
- 📖 **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.
|
- 🖥️ **A clean management app** — manage your devices, shortcuts, modes, and privacy from one tidy window.
|
||||||
|
|
||||||
@ -67,7 +67,7 @@ No subscription. No per-seat fees. One payment, lifetime updates.
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[**⬇️ Get GrammaGem**](https://grammagem.app/#pricing)
|
[**⬇️ Get GrammarGem**](https://www.grammargem.com/#pricing)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -85,17 +85,17 @@ No. Core features work fully offline.
|
|||||||
No — it’s a one-time purchase with lifetime updates and a 14-day money-back guarantee.
|
No — it’s a one-time purchase with lifetime updates and a 14-day money-back guarantee.
|
||||||
|
|
||||||
**What about plagiarism detection?**
|
**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
|
## Built in public
|
||||||
|
|
||||||
GrammaGem is open source under the **MIT License**. We believe a privacy tool should be one you can actually inspect. Issues and pull requests are welcome.
|
GrammarGem is open source under the **MIT License**. We believe a privacy tool should be one you can actually inspect. Issues and pull requests are welcome.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
**[grammagem.app](https://grammagem.app)**
|
**[grammargem.com](https://www.grammargem.com)**
|
||||||
|
|
||||||
© 2026 FounderGem LLC · 1309 Coffeen Avenue STE 1200, Sheridan, WY 82801, USA
|
© 2026 FounderGem LLC · 1309 Coffeen Avenue STE 1200, Sheridan, WY 82801, USA
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# Built-in Writing Modes
|
# Built-in Writing Modes
|
||||||
|
|
||||||
Each Mode is a named system-prompt preset for the local LLM. The canonical,
|
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.
|
these JSON files mirror them for transparency and easy editing.
|
||||||
|
|
||||||
| Mode | Tier | In public repo? |
|
| Mode | Tier | In public repo? |
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// CHarper is a header-only shim that exposes libharper_ffi's C ABI (declared in
|
// 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
|
// include/harper.h) to Swift as the `CHarper` module. The actual symbols are
|
||||||
// provided by libharper_ffi.a, linked via the GrammaGem target's linkerSettings.
|
// 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.
|
// This translation unit exists only so SwiftPM treats CHarper as a C target.
|
||||||
|
|||||||
@ -8,8 +8,8 @@ final class CustomModesStore: ObservableObject {
|
|||||||
/// bundleID -> mode id (overrides the default App-Aware mapping).
|
/// bundleID -> mode id (overrides the default App-Aware mapping).
|
||||||
@Published private(set) var appOverrides: [String: String]
|
@Published private(set) var appOverrides: [String: String]
|
||||||
|
|
||||||
private let modesKey = "GrammaGem.customModes"
|
private let modesKey = "GrammarGem.customModes"
|
||||||
private let mapKey = "GrammaGem.appModeOverrides"
|
private let mapKey = "GrammarGem.appModeOverrides"
|
||||||
private let d = UserDefaults.standard
|
private let d = UserDefaults.standard
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
|||||||
@ -38,7 +38,7 @@ final class ModelManager: ObservableObject {
|
|||||||
|
|
||||||
nonisolated static func modelsDirectory() -> URL {
|
nonisolated static func modelsDirectory() -> URL {
|
||||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
return base.appendingPathComponent("GrammaGem/Models", isDirectory: true)
|
return base.appendingPathComponent("GrammarGem/Models", isDirectory: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func modelDir(_ repo: String) -> URL {
|
nonisolated static func modelDir(_ repo: String) -> URL {
|
||||||
|
|||||||
@ -115,7 +115,7 @@ final class AppState: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Task { await license.validateIfNeeded() }
|
Task { await license.validateIfNeeded() }
|
||||||
Log.app.info("GrammaGem started. Licensed: \(self.license.isLicensed, privacy: .public)")
|
Log.app.info("GrammarGem started. Licensed: \(self.license.isLicensed, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hotkey handling
|
// MARK: - Hotkey handling
|
||||||
@ -205,19 +205,19 @@ final class AppState: ObservableObject {
|
|||||||
return decision
|
return decision
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pause/resume all of GrammaGem (hotkeys + live monitoring).
|
/// Pause/resume all of GrammarGem (hotkeys + live monitoring).
|
||||||
func togglePause() {
|
func togglePause() {
|
||||||
isPaused.toggle()
|
isPaused.toggle()
|
||||||
liveMonitor.setPaused(isPaused)
|
liveMonitor.setPaused(isPaused)
|
||||||
lastStatus = isPaused ? "Paused" : "Ready"
|
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? {
|
private func blockReason() -> String? {
|
||||||
if isPaused { return "GrammaGem is paused." }
|
if isPaused { return "GrammarGem is paused." }
|
||||||
let front = detector.frontmost()
|
let front = detector.frontmost()
|
||||||
if exclusions.isBlocked(bundleID: front?.bundleID, domain: detector.frontmostDomain()) {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@ -282,7 +282,7 @@ final class AppState: ObservableObject {
|
|||||||
if onboardingWindow == nil {
|
if onboardingWindow == nil {
|
||||||
let host = NSHostingController(rootView: OnboardingView().environmentObject(self))
|
let host = NSHostingController(rootView: OnboardingView().environmentObject(self))
|
||||||
let win = NSWindow(contentViewController: host)
|
let win = NSWindow(contentViewController: host)
|
||||||
win.title = "Welcome to GrammaGem"
|
win.title = "Welcome to GrammarGem"
|
||||||
win.styleMask = [.titled, .closable, .fullSizeContentView]
|
win.styleMask = [.titled, .closable, .fullSizeContentView]
|
||||||
win.setContentSize(NSSize(width: 560, height: 720))
|
win.setContentSize(NSSize(width: 560, height: 720))
|
||||||
win.isReleasedWhenClosed = false
|
win.isReleasedWhenClosed = false
|
||||||
@ -311,7 +311,7 @@ final class AppState: ObservableObject {
|
|||||||
.environmentObject(liveMonitor)
|
.environmentObject(liveMonitor)
|
||||||
let host = NSHostingController(rootView: root)
|
let host = NSHostingController(rootView: root)
|
||||||
let win = NSWindow(contentViewController: host)
|
let win = NSWindow(contentViewController: host)
|
||||||
win.title = "GrammaGem"
|
win.title = "GrammarGem"
|
||||||
win.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView]
|
win.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView]
|
||||||
win.setContentSize(NSSize(width: 940, height: 660))
|
win.setContentSize(NSSize(width: 940, height: 660))
|
||||||
win.minSize = NSSize(width: 820, height: 560)
|
win.minSize = NSSize(width: 820, height: 560)
|
||||||
@ -349,7 +349,7 @@ final class AppState: ObservableObject {
|
|||||||
if askWindow == nil {
|
if askWindow == nil {
|
||||||
let host = NSHostingController(rootView: AskView().environmentObject(self))
|
let host = NSHostingController(rootView: AskView().environmentObject(self))
|
||||||
let win = NSWindow(contentViewController: host)
|
let win = NSWindow(contentViewController: host)
|
||||||
win.title = "Ask GrammaGem"
|
win.title = "Ask GrammarGem"
|
||||||
win.styleMask = [.titled, .closable]
|
win.styleMask = [.titled, .closable]
|
||||||
win.setContentSize(NSSize(width: 460, height: 220))
|
win.setContentSize(NSSize(width: 460, height: 220))
|
||||||
win.isReleasedWhenClosed = false
|
win.isReleasedWhenClosed = false
|
||||||
|
|||||||
@ -8,7 +8,7 @@ struct AskView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
Text("Ask GrammaGem")
|
Text("Ask GrammarGem")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text("Acts on the text selected in the frontmost app. Runs on-device.")
|
Text("Acts on the text selected in the frontmost app. Runs on-device.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import AppKit
|
|||||||
/// and runs one real generation (verifies the MLX runtime + metallib headlessly),
|
/// and runs one real generation (verifies the MLX runtime + metallib headlessly),
|
||||||
/// then exits. Otherwise the normal menu-bar app launches.
|
/// then exits. Otherwise the normal menu-bar app launches.
|
||||||
@main
|
@main
|
||||||
enum GrammaGemMain {
|
enum GrammarGemMain {
|
||||||
static func main() {
|
static func main() {
|
||||||
if CommandLine.arguments.contains("--mlx-selftest") {
|
if CommandLine.arguments.contains("--mlx-selftest") {
|
||||||
MLXSelfTest.run()
|
MLXSelfTest.run()
|
||||||
@ -16,7 +16,7 @@ enum GrammaGemMain {
|
|||||||
UpdaterSelfTest.run(feedURL: feed)
|
UpdaterSelfTest.run(feedURL: feed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
GrammaGemApp.main()
|
GrammarGemApp.main()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,7 +47,7 @@ enum MLXSelfTest {
|
|||||||
/// App entry point. A menu-bar (`LSUIElement`) SwiftUI app: the only persistent
|
/// App entry point. A menu-bar (`LSUIElement`) SwiftUI app: the only persistent
|
||||||
/// scene is the `MenuBarExtra`; Settings uses the standard scene; Onboarding and
|
/// scene is the `MenuBarExtra`; Settings uses the standard scene; Onboarding and
|
||||||
/// Ask are AppKit-hosted windows opened on demand (see `AppState`).
|
/// Ask are AppKit-hosted windows opened on demand (see `AppState`).
|
||||||
struct GrammaGemApp: App {
|
struct GrammarGemApp: App {
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
@StateObject private var app = AppState.shared
|
@StateObject private var app = AppState.shared
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ struct GeneralView: View {
|
|||||||
@EnvironmentObject private var permissions: Permissions
|
@EnvironmentObject private var permissions: Permissions
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
DetailScaffold(title: "General", subtitle: "Make GrammaGem feel like yours.") {
|
DetailScaffold(title: "General", subtitle: "Make GrammarGem feel like yours.") {
|
||||||
Card {
|
Card {
|
||||||
Toggle("Launch at login", isOn: $prefs.launchAtLogin)
|
Toggle("Launch at login", isOn: $prefs.launchAtLogin)
|
||||||
Divider()
|
Divider()
|
||||||
@ -134,7 +134,7 @@ struct AboutView: View {
|
|||||||
.frame(width: 56, height: 56)
|
.frame(width: 56, height: 56)
|
||||||
.background(GG.emerald.opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
|
.background(GG.emerald.opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("GrammaGem").font(.title2.bold())
|
Text("GrammarGem").font(.title2.bold())
|
||||||
Text("Version \(AppConfig.appVersion)").foregroundStyle(.secondary)
|
Text("Version \(AppConfig.appVersion)").foregroundStyle(.secondary)
|
||||||
Text("The private, on-device writing assistant for Mac.")
|
Text("The private, on-device writing assistant for Mac.")
|
||||||
.font(.callout).foregroundStyle(.secondary)
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
@ -153,7 +153,7 @@ struct AboutView: View {
|
|||||||
|
|
||||||
Card {
|
Card {
|
||||||
Text("Open source").font(.headline)
|
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)
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -91,7 +91,7 @@ struct DashboardView: View {
|
|||||||
Label("Fix selection", systemImage: "checkmark.circle")
|
Label("Fix selection", systemImage: "checkmark.circle")
|
||||||
}
|
}
|
||||||
Button { app.showAsk() } label: {
|
Button { app.showAsk() } label: {
|
||||||
Label("Ask GrammaGem", systemImage: "sparkles")
|
Label("Ask GrammarGem", systemImage: "sparkles")
|
||||||
}
|
}
|
||||||
if !license.isLicensed {
|
if !license.isLicensed {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|||||||
@ -12,7 +12,7 @@ struct DictionaryView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
DetailScaffold(
|
DetailScaffold(
|
||||||
title: "Personal Dictionary",
|
title: "Personal Dictionary",
|
||||||
subtitle: "Words GrammaGem should never “correct” — names, brands, jargon."
|
subtitle: "Words GrammarGem should never “correct” — names, brands, jargon."
|
||||||
) {
|
) {
|
||||||
Card {
|
Card {
|
||||||
HStack {
|
HStack {
|
||||||
|
|||||||
@ -83,7 +83,7 @@ struct DevicesView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Link("Open license portal", destination: AppConfig.modelPortalURL)
|
Link("Open license portal", destination: AppConfig.modelPortalURL)
|
||||||
Spacer()
|
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)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,12 +8,12 @@ struct ExclusionsView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
DetailScaffold(
|
DetailScaffold(
|
||||||
title: "Page Blocker",
|
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 {
|
Card {
|
||||||
Label("How it works", systemImage: "hand.raised")
|
Label("How it works", systemImage: "hand.raised")
|
||||||
.font(.headline)
|
.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)
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
struct MainWindowView: View {
|
||||||
@State private var selection: MainSection?
|
@State private var selection: MainSection?
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ struct ShortcutsView: View {
|
|||||||
combo: $prefs.fixHotkey)
|
combo: $prefs.fixHotkey)
|
||||||
Divider()
|
Divider()
|
||||||
shortcutRow(
|
shortcutRow(
|
||||||
name: "Ask GrammaGem",
|
name: "Ask GrammarGem",
|
||||||
detail: "Rewrite with an instruction",
|
detail: "Rewrite with an instruction",
|
||||||
combo: $prefs.askHotkey)
|
combo: $prefs.askHotkey)
|
||||||
}
|
}
|
||||||
@ -141,7 +141,7 @@ struct PrivacyView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Card {
|
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")
|
networkRow("One-time AI model download", "Hugging Face, on first run")
|
||||||
Divider()
|
Divider()
|
||||||
networkRow("License activation & checks", "To enforce your device cap")
|
networkRow("License activation & checks", "To enforce your device cap")
|
||||||
|
|||||||
@ -15,7 +15,7 @@ struct WritingModesView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
DetailScaffold(
|
DetailScaffold(
|
||||||
title: "Writing Modes",
|
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
|
builtInCard
|
||||||
customCard
|
customCard
|
||||||
@ -88,7 +88,7 @@ struct WritingModesView: View {
|
|||||||
private var appAwareCard: some View {
|
private var appAwareCard: some View {
|
||||||
Card {
|
Card {
|
||||||
HStack { Text("App-aware switching").font(.headline); if !paid { UpgradeBadge() } }
|
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)
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
|
||||||
if !customModes.appOverrides.isEmpty {
|
if !customModes.appOverrides.isEmpty {
|
||||||
|
|||||||
@ -49,7 +49,7 @@ struct MenuBarContent: View {
|
|||||||
actionRow(title: "Fix selection", shortcut: app.preferences.fixHotkey.display) {
|
actionRow(title: "Fix selection", shortcut: app.preferences.fixHotkey.display) {
|
||||||
Task { await app.runFix() }
|
Task { await app.runFix() }
|
||||||
}
|
}
|
||||||
actionRow(title: "Ask GrammaGem…", shortcut: app.preferences.askHotkey.display) {
|
actionRow(title: "Ask GrammarGem…", shortcut: app.preferences.askHotkey.display) {
|
||||||
app.showAsk()
|
app.showAsk()
|
||||||
}
|
}
|
||||||
if app.gate.appAwareEnabled {
|
if app.gate.appAwareEnabled {
|
||||||
@ -57,14 +57,14 @@ struct MenuBarContent: View {
|
|||||||
Task { await app.runAppAwareRewrite() }
|
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()
|
app.togglePause()
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
actionRow(title: "Show live issues…", shortcut: nil) { app.showLiveCheck() }
|
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: "Manage devices…", shortcut: nil) { app.showMainWindow(select: .devices) }
|
||||||
actionRow(title: "Page blocker…", shortcut: nil) { app.showMainWindow(select: .exclusions) }
|
actionRow(title: "Page blocker…", shortcut: nil) { app.showMainWindow(select: .exclusions) }
|
||||||
actionRow(title: "Check for Updates…", shortcut: nil) { app.updater.checkForUpdates() }
|
actionRow(title: "Check for Updates…", shortcut: nil) { app.updater.checkForUpdates() }
|
||||||
@ -90,7 +90,7 @@ struct MenuBarContent: View {
|
|||||||
.frame(width: 290)
|
.frame(width: 290)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shown when Sparkle has found a newer signed build on grammagem.app.
|
/// Shown when Sparkle has found a newer signed build on grammargem.com.
|
||||||
private func updateBanner(version: String) -> some View {
|
private func updateBanner(version: String) -> some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "arrow.down.circle.fill").foregroundStyle(GG.emerald)
|
Image(systemName: "arrow.down.circle.fill").foregroundStyle(GG.emerald)
|
||||||
@ -110,7 +110,7 @@ struct MenuBarContent: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "pencil.and.scribble").foregroundStyle(.tint)
|
Image(systemName: "pencil.and.scribble").foregroundStyle(.tint)
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
Text("GrammaGem").font(.headline)
|
Text("GrammarGem").font(.headline)
|
||||||
Text(app.license.tier.displayName + (app.license.isLicensed ? " · licensed" : " · free"))
|
Text(app.license.tier.displayName + (app.license.isLicensed ? " · licensed" : " · free"))
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// First-run onboarding: explains why GrammaGem needs Accessibility, deep-links
|
/// First-run onboarding: explains why GrammarGem needs Accessibility, deep-links
|
||||||
/// to System Settings, and advances automatically the moment permission is
|
/// to System Settings, and advances automatically the moment permission is
|
||||||
/// granted (even if granted directly in System Settings).
|
/// granted (even if granted directly in System Settings).
|
||||||
struct OnboardingView: View {
|
struct OnboardingView: View {
|
||||||
@ -18,7 +18,7 @@ struct OnboardingView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Welcome to GrammaGem")
|
Text("Welcome to GrammarGem")
|
||||||
.font(.largeTitle.bold())
|
.font(.largeTitle.bold())
|
||||||
Text("A private, on-device writing assistant that works in every app.")
|
Text("A private, on-device writing assistant that works in every app.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@ -36,7 +36,7 @@ struct OnboardingView: View {
|
|||||||
stepCard(
|
stepCard(
|
||||||
number: 1,
|
number: 1,
|
||||||
title: "Turn it on everywhere",
|
title: "Turn it on everywhere",
|
||||||
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.",
|
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
|
granted: permissions.accessibilityTrusted
|
||||||
) {
|
) {
|
||||||
if permissions.accessibilityTrusted {
|
if permissions.accessibilityTrusted {
|
||||||
@ -47,7 +47,7 @@ struct OnboardingView: View {
|
|||||||
Button("Open System Settings") { permissions.openAccessibilitySettings() }
|
Button("Open System Settings") { permissions.openAccessibilitySettings() }
|
||||||
Button("Request permission") { permissions.requestAccessibility() }
|
Button("Request permission") { permissions.requestAccessibility() }
|
||||||
}
|
}
|
||||||
Text("This updates automatically once you flip GrammaGem on in Accessibility.")
|
Text("This updates automatically once you flip GrammarGem on in Accessibility.")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -114,7 +114,7 @@ struct OnboardingView: View {
|
|||||||
/// A safe, on-rails first-run demo: a local sample with planted mistakes that
|
/// 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
|
/// 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
|
/// (never a live Accessibility element), so it's completely risk-free — the
|
||||||
/// "guaranteed visible win" before GrammaGem asks for anything.
|
/// "guaranteed visible win" before GrammarGem asks for anything.
|
||||||
private struct DemoStep: View {
|
private struct DemoStep: View {
|
||||||
@State private var text = "i has wrote alot of mistakes here, and it dont look profesional"
|
@State private var text = "i has wrote alot of mistakes here, and it dont look profesional"
|
||||||
@State private var issues: [Suggestion] = []
|
@State private var issues: [Suggestion] = []
|
||||||
@ -151,7 +151,7 @@ private struct DemoStep: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var headline: String {
|
private var headline: String {
|
||||||
if cleaned { return "Clean ✨ — GrammaGem does this in every app you type." }
|
if cleaned { return "Clean ✨ — GrammarGem does this in every app you type." }
|
||||||
if issues.isEmpty { return "Checking…" }
|
if issues.isEmpty { return "Checking…" }
|
||||||
return "We spotted \(issues.count) thing\(issues.count == 1 ? "" : "s") to tidy up — one click fixes them."
|
return "We spotted \(issues.count) thing\(issues.count == 1 ? "" : "s") to tidy up — one click fixes them."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,7 @@ private struct GeneralSettings: View {
|
|||||||
Form {
|
Form {
|
||||||
Section("Shortcuts") {
|
Section("Shortcuts") {
|
||||||
LabeledContent("Fix selection", value: "⌘;")
|
LabeledContent("Fix selection", value: "⌘;")
|
||||||
LabeledContent("Ask GrammaGem", value: "⌘'")
|
LabeledContent("Ask GrammarGem", value: "⌘'")
|
||||||
Text("Custom shortcuts coming via the KeyboardShortcuts recorder (see README).")
|
Text("Custom shortcuts coming via the KeyboardShortcuts recorder (see README).")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@ -147,7 +147,7 @@ private struct DictionarySettings: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
TextField("Add a word GrammaGem should never correct", text: $newWord)
|
TextField("Add a word GrammarGem should never correct", text: $newWord)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.onSubmit(add)
|
.onSubmit(add)
|
||||||
Button("Add", action: add).disabled(newWord.trimmingCharacters(in: .whitespaces).isEmpty)
|
Button("Add", action: add).disabled(newWord.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Compile-time configuration. The Lemon Squeezy identifiers are hard-coded so
|
/// 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.
|
/// the claimed tier) rather than to some other Lemon Squeezy product.
|
||||||
/// TODO(real-integration): replace every `TODO_*` value with your real IDs.
|
/// TODO(real-integration): replace every `TODO_*` value with your real IDs.
|
||||||
enum AppConfig {
|
enum AppConfig {
|
||||||
static let appName = "GrammaGem"
|
static let appName = "GrammarGem"
|
||||||
static let bundleIdentifier = "com.foundergem.grammagem" // TODO(real-integration)
|
static let bundleIdentifier = "com.foundergem.grammargem" // TODO(real-integration)
|
||||||
static let supportEmail = "support@grammagem.app" // TODO(real-integration)
|
static let supportEmail = "support@grammargem.com" // TODO(real-integration)
|
||||||
static let modelPortalURL = URL(string: "https://grammagem.app/license")! // TODO
|
static let modelPortalURL = URL(string: "https://www.grammargem.com/license")! // TODO
|
||||||
static let websiteURL = URL(string: "https://grammagem.app")!
|
static let websiteURL = URL(string: "https://www.grammargem.com")!
|
||||||
static let appVersion = "0.1.0"
|
static let appVersion = "0.1.0"
|
||||||
|
|
||||||
// Legal entity (shown in About / legal).
|
// Legal entity (shown in About / legal).
|
||||||
|
|||||||
@ -72,7 +72,7 @@ final class FeatureGate: ObservableObject {
|
|||||||
/// Free-tier AI action counter. Persists `{date, count}` in UserDefaults and
|
/// Free-tier AI action counter. Persists `{date, count}` in UserDefaults and
|
||||||
/// resets at local midnight (compares the stored local date string to today's).
|
/// resets at local midnight (compares the stored local date string to today's).
|
||||||
final class DailyCounter {
|
final class DailyCounter {
|
||||||
private let key = "GrammaGem.aiActions.daily"
|
private let key = "GrammarGem.aiActions.daily"
|
||||||
private let defaults = UserDefaults.standard
|
private let defaults = UserDefaults.standard
|
||||||
|
|
||||||
private static let formatter: DateFormatter = {
|
private static let formatter: DateFormatter = {
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import Foundation
|
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
|
/// 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).
|
/// entries on free, unlimited on paid (enforced by `FeatureGate`, not here).
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PersonalDictionary: ObservableObject {
|
final class PersonalDictionary: ObservableObject {
|
||||||
@Published private(set) var entries: [String]
|
@Published private(set) var entries: [String]
|
||||||
|
|
||||||
private let key = "GrammaGem.personalDictionary"
|
private let key = "GrammarGem.personalDictionary"
|
||||||
private let defaults = UserDefaults.standard
|
private let defaults = UserDefaults.standard
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
|||||||
@ -38,7 +38,7 @@ enum DeviceFingerprint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func fallbackID() -> String {
|
private static func fallbackID() -> String {
|
||||||
let key = "GrammaGem.fallbackDeviceID"
|
let key = "GrammarGem.fallbackDeviceID"
|
||||||
if let existing = UserDefaults.standard.string(forKey: key) { return existing }
|
if let existing = UserDefaults.standard.string(forKey: key) { return existing }
|
||||||
let id = UUID().uuidString
|
let id = UUID().uuidString
|
||||||
UserDefaults.standard.set(id, forKey: key)
|
UserDefaults.standard.set(id, forKey: key)
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
/// Talks to the **Lemon Squeezy License API** directly from the app — no server
|
/// 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
|
/// 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 {
|
final class LemonSqueezyClient {
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
private let base = AppConfig.LemonSqueezy.apiBase
|
private let base = AppConfig.LemonSqueezy.apiBase
|
||||||
@ -54,7 +54,7 @@ final class LemonSqueezyClient {
|
|||||||
// MARK: - Hard verification (spec §6)
|
// MARK: - Hard verification (spec §6)
|
||||||
|
|
||||||
/// Confirm the key is for OUR store/product, then resolve which tier its
|
/// 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 {
|
private func verifyAndResolveTier(meta: LSMeta?, licenseKey: LSLicenseKey?) throws -> Tier {
|
||||||
guard let meta else { throw LicenseError.wrongProduct }
|
guard let meta else { throw LicenseError.wrongProduct }
|
||||||
|
|
||||||
|
|||||||
@ -62,7 +62,7 @@ struct LSLicenseKey: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `meta` carries the store/product/variant identity we *hard-verify* against
|
/// `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 {
|
struct LSMeta: Codable {
|
||||||
let store_id: Int?
|
let store_id: Int?
|
||||||
let product_id: Int?
|
let product_id: Int?
|
||||||
|
|||||||
@ -14,12 +14,12 @@ final class UsageStats: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// What a Grammarly-style subscription runs per year (USD) — used only for the
|
/// What a Grammarly-style subscription runs per year (USD) — used only for the
|
||||||
/// honest, local "subscription you didn't pay" figure. GrammaGem is one-time.
|
/// honest, local "subscription you didn't pay" figure. GrammarGem is one-time.
|
||||||
static let comparableAnnualSubscriptionUSD = 144.0
|
static let comparableAnnualSubscriptionUSD = 144.0
|
||||||
|
|
||||||
@Published private(set) var data: Snapshot
|
@Published private(set) var data: Snapshot
|
||||||
|
|
||||||
private let key = "GrammaGem.usageStats"
|
private let key = "GrammarGem.usageStats"
|
||||||
private let d = UserDefaults.standard
|
private let d = UserDefaults.standard
|
||||||
|
|
||||||
private static let fmt: DateFormatter = {
|
private static let fmt: DateFormatter = {
|
||||||
@ -64,7 +64,7 @@ final class UsageStats: ObservableObject {
|
|||||||
var currentStreak: Int { Self.streak(perDay: data.perDay, asOf: Date()) }
|
var currentStreak: Int { Self.streak(perDay: data.perDay, asOf: Date()) }
|
||||||
|
|
||||||
/// The Grammarly subscription you DIDN'T pay: their annual price prorated over
|
/// The Grammarly subscription you DIDN'T pay: their annual price prorated over
|
||||||
/// how long you've owned GrammaGem. Honest, local, illustrative — and it only
|
/// 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).
|
/// grows, which is the point of a one-time purchase (see the value plan §5).
|
||||||
var subscriptionAvoidedUSD: Int {
|
var subscriptionAvoidedUSD: Int {
|
||||||
Self.subscriptionAvoidedUSD(
|
Self.subscriptionAvoidedUSD(
|
||||||
|
|||||||
@ -43,7 +43,7 @@ enum AX {
|
|||||||
///
|
///
|
||||||
/// On recent macOS, `AXUIElementCreateSystemWide()` returns
|
/// On recent macOS, `AXUIElementCreateSystemWide()` returns
|
||||||
/// `kAXErrorCannotComplete` (-25204) for *every* query made by a background /
|
/// `kAXErrorCannotComplete` (-25204) for *every* query made by a background /
|
||||||
/// accessory process (which is exactly what a menu-bar app like GrammaGem is).
|
/// 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
|
/// 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
|
/// app "sees" no text or text fields at all. Resolving the focused element
|
||||||
/// through the **frontmost application's** own AX element works reliably in
|
/// through the **frontmost application's** own AX element works reliably in
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AppKit
|
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.
|
/// out of the way — password managers, banking sites, anything sensitive.
|
||||||
/// When the frontmost context is excluded, live monitoring and the hotkeys are
|
/// When the frontmost context is excluded, live monitoring and the hotkeys are
|
||||||
/// suppressed.
|
/// suppressed.
|
||||||
@ -10,8 +10,8 @@ final class Exclusions: ObservableObject {
|
|||||||
@Published private(set) var blockedApps: [String] // bundle identifiers
|
@Published private(set) var blockedApps: [String] // bundle identifiers
|
||||||
@Published private(set) var blockedDomains: [String] // hostnames, e.g. "chase.com"
|
@Published private(set) var blockedDomains: [String] // hostnames, e.g. "chase.com"
|
||||||
|
|
||||||
private let appsKey = "GrammaGem.blockedApps"
|
private let appsKey = "GrammarGem.blockedApps"
|
||||||
private let domainsKey = "GrammaGem.blockedDomains"
|
private let domainsKey = "GrammarGem.blockedDomains"
|
||||||
private let d = UserDefaults.standard
|
private let d = UserDefaults.standard
|
||||||
|
|
||||||
/// Common sensitive apps offered as one-tap suggestions in the UI.
|
/// 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).
|
/// a browser's URL can't be read).
|
||||||
var hasDomainRules: Bool { !blockedDomains.isEmpty }
|
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 {
|
func isBlocked(bundleID: String?, domain: String?) -> Bool {
|
||||||
if let bundleID, blockedApps.contains(bundleID) { return true }
|
if let bundleID, blockedApps.contains(bundleID) { return true }
|
||||||
if let host = domain?.lowercased(), !host.isEmpty {
|
if let host = domain?.lowercased(), !host.isEmpty {
|
||||||
|
|||||||
@ -19,7 +19,7 @@ struct DetectedField: Identifiable {
|
|||||||
/// Performance & safety: the Accessibility-tree traversal (the part that can hang
|
/// 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
|
/// 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
|
/// 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.
|
/// element's *live* value so it can't overwrite text typed since the last scan.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LiveMonitor: ObservableObject {
|
final class LiveMonitor: ObservableObject {
|
||||||
@ -179,7 +179,7 @@ final class LiveMonitor: ObservableObject {
|
|||||||
if scanning && !force { return } // don't pile up scans
|
if scanning && !force { return } // don't pile up scans
|
||||||
guard let app = NSWorkspace.shared.frontmostApplication else { clear(); return }
|
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 }
|
if app.processIdentifier == ownPID { clear(); return }
|
||||||
activeAppName = app.localizedName ?? ""
|
activeAppName = app.localizedName ?? ""
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import Foundation
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ApplicationServices
|
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
|
/// text system-wide: **Accessibility** (required) and, for the synthetic-keystroke
|
||||||
/// clipboard fallback, **Input Monitoring** may also be prompted by the system.
|
/// clipboard fallback, **Input Monitoring** may also be prompted by the system.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@ -13,7 +13,7 @@ struct Snippet: Codable, Identifiable, Equatable {
|
|||||||
final class SnippetStore: ObservableObject {
|
final class SnippetStore: ObservableObject {
|
||||||
@Published private(set) var snippets: [Snippet]
|
@Published private(set) var snippets: [Snippet]
|
||||||
|
|
||||||
private let key = "GrammaGem.snippets"
|
private let key = "GrammarGem.snippets"
|
||||||
private let d = UserDefaults.standard
|
private let d = UserDefaults.standard
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import ApplicationServices
|
|||||||
import Carbon.HIToolbox
|
import Carbon.HIToolbox
|
||||||
|
|
||||||
/// Reads the user's selected text from the frontmost app and writes a corrected
|
/// 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):
|
/// Two paths, mirroring the spec (§4):
|
||||||
/// 1. **Accessibility API** (preferred): read/write `kAXSelectedTextAttribute`
|
/// 1. **Accessibility API** (preferred): read/write `kAXSelectedTextAttribute`
|
||||||
@ -27,7 +27,7 @@ final class TextCapture {
|
|||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .noSelection: return "Select some text first, then press the hotkey."
|
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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ struct ScannedField {
|
|||||||
|
|
||||||
/// Walks the Accessibility tree of the frontmost window and collects every
|
/// Walks the Accessibility tree of the frontmost window and collects every
|
||||||
/// editable text element (text fields, text areas, combo/search boxes) with
|
/// 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
|
/// 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.
|
/// every cross-process message has a timeout so a wedged app can't hang the scan.
|
||||||
///
|
///
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Sparkle
|
import Sparkle
|
||||||
|
|
||||||
/// Wraps Sparkle's updater. It background-checks the appcast on grammagem.app and
|
/// Wraps Sparkle's updater. It background-checks the appcast on grammargem.com and
|
||||||
/// publishes `updateAvailableVersion` when a newer, EdDSA-verified build exists, so
|
/// publishes `updateAvailableVersion` when a newer, EdDSA-verified build exists, so
|
||||||
/// the menu bar can show an "Update available" button. Triggering an update runs
|
/// the menu bar can show an "Update available" button. Triggering an update runs
|
||||||
/// Sparkle's verified download + install flow.
|
/// Sparkle's verified download + install flow.
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
@testable import GrammaGem
|
@testable import GrammarGem
|
||||||
|
|
||||||
final class GrammaGemTests: XCTestCase {
|
final class GrammarGemTests: XCTestCase {
|
||||||
|
|
||||||
// MARK: - Tiers / entitlements
|
// MARK: - Tiers / entitlements
|
||||||
|
|
||||||
@ -117,10 +117,10 @@ final class GrammaGemTests: XCTestCase {
|
|||||||
/// MLX integration end-to-end.
|
/// MLX integration end-to-end.
|
||||||
func testMLXRealGenerationIfModelPresent() async throws {
|
func testMLXRealGenerationIfModelPresent() async throws {
|
||||||
// Opt-in: `swift test` (CLI) can't place mlx-swift's metallib, so this is
|
// 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 GRAMMAGEM_MLX_TEST=1
|
// gated. Run it via the app's `--mlx-selftest`, or set GRAMMARGEM_MLX_TEST=1
|
||||||
// with the metallib colocated. See scripts/build-metallib.sh.
|
// with the metallib colocated. See scripts/build-metallib.sh.
|
||||||
guard ProcessInfo.processInfo.environment["GRAMMAGEM_MLX_TEST"] == "1" else {
|
guard ProcessInfo.processInfo.environment["GRAMMARGEM_MLX_TEST"] == "1" else {
|
||||||
throw XCTSkip("set GRAMMAGEM_MLX_TEST=1 (metallib colocated) to run the MLX integration test")
|
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 {
|
guard let dir = ModelManager.completedModelDirectory(repo: AppConfig.Model.defaultRepo) else {
|
||||||
throw XCTSkip("on-device model not downloaded; skipping MLX integration test")
|
throw XCTSkip("on-device model not downloaded; skipping MLX integration test")
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Grammarly Value Teardown -> GrammaGem Plan
|
# Grammarly Value Teardown -> GrammarGem Plan
|
||||||
|
|
||||||
*Senior product + Swift engineering review. Every claim below is grounded in the current GrammaGem source. File references use absolute paths; the load-bearing bug is the mis-casing in `SystemGrammarEngine.swift:42-45`.*
|
*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`.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -8,34 +8,34 @@
|
|||||||
|
|
||||||
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:
|
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. GrammaGem's engine currently *demonstrably violates this* (`problemm` -> `Problem` mid-sentence). Fix this first; nothing else matters until it's solid.
|
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. GrammaGem has an `Apply` button but no dismiss/ignore/add-to-dictionary, no keyboard accept, and the apply path is brittle (whole-field AX overwrite).
|
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. GrammaGem has a raw issue count (3 colors) and *zero* score concept.
|
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. GrammaGem's onboarding only handles Accessibility + model download — it never demonstrates the correction loop at all.
|
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. GrammaGem 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.
|
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.
|
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." GrammaGem's identical mechanics should be framed "yours once, forever, on this Mac."
|
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 GrammaGem should do it
|
## 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)
|
### 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.
|
- **Grammarly:** ranks by confidence, suppresses low-confidence edits, never garbles surrounding text, preserves casing, and limits bulk-apply to high-confidence objective mechanics only.
|
||||||
- **GrammaGem 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`).
|
- **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.
|
- **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/`.
|
- **Files:** `SystemGrammarEngine.swift`, `HarperEngine.swift`, `GrammarEngine.swift`, `Models.swift`, `harper-ffi/`.
|
||||||
- **Impact: high · Effort: L**
|
- **Impact: high · Effort: L**
|
||||||
|
|
||||||
### Mechanic 2 — The fix-as-button atomic loop (+ dismiss / ignore / add-to-dictionary)
|
### 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.
|
- **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.
|
||||||
- **GrammaGem 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.
|
- **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):**
|
- **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.
|
- Make the replacement text itself the primary tappable control in the card (the chip *is* the fix), styled by category color.
|
||||||
- Add a per-suggestion overflow: **Dismiss** (model a `dismissedIDs: Set<UUID>` on the monitor so it survives re-scans), **Ignore word** / **Add to dictionary** (route into `PersonalDictionary` -> `ignoreList`), gated by `freeDictionaryCap`.
|
- Add a per-suggestion overflow: **Dismiss** (model a `dismissedIDs: Set<UUID>` on the monitor so it survives re-scans), **Ignore word** / **Add to dictionary** (route into `PersonalDictionary` -> `ignoreList`), gated by `freeDictionaryCap`.
|
||||||
@ -47,9 +47,9 @@ The strategic asymmetry to exploit throughout: every Grammarly value moment carr
|
|||||||
|
|
||||||
### Mechanic 3 — Categorization, counts, and an on-device document score
|
### 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.
|
- **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.
|
||||||
- **GrammaGem 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**.
|
- **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):**
|
- **Do (on-device, no cloud, no percentile-vs-others):**
|
||||||
- Introduce GrammaGem'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).
|
- 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`).
|
- 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.
|
- 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.
|
- **Order cards by severity then document position** (objective spelling/grammar first) so the first accept is a high-confidence win.
|
||||||
@ -59,18 +59,18 @@ The strategic asymmetry to exploit throughout: every Grammarly value moment carr
|
|||||||
|
|
||||||
### Mechanic 4 — The first-run "wow" (see §4 for the full sequence)
|
### 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.
|
- **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.
|
||||||
- **GrammaGem today:** `OnboardingView` only covers Accessibility + model download (`OnboardingView.swift`); it never shows the correction loop.
|
- **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.
|
- **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.
|
- **Files:** `OnboardingView.swift`, plus a small `DemoDocument` view reusing the highlight + apply UI.
|
||||||
- **Impact: high · Effort: M**
|
- **Impact: high · Effort: M**
|
||||||
|
|
||||||
### Mechanic 5 — Ongoing perceived value (privacy-native, self-vs-self)
|
### 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.
|
- **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.
|
||||||
- **GrammaGem 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.
|
- **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):**
|
- **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.
|
- **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.
|
- **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 GrammaGem recovered M times over," computed locally. This is the strongest one-time-purchase value teaser and it lives where the user actually is.
|
- **"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).
|
- **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.
|
- **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.
|
- Tune `estimatedMinutesSaved` to scale by words changed / action type so the headline "was it worth it?" number is credible.
|
||||||
@ -79,7 +79,7 @@ The strategic asymmetry to exploit throughout: every Grammarly value moment carr
|
|||||||
|
|
||||||
### Mechanic 6 — The generative "writing partner" layer (real LLM, honest gating)
|
### 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.
|
- **Grammarly:** sentence rewrites ("Replace with This Version" + Highlight-Changes diff), tone detection (emoji + label), tone adjustment, conciseness, GrammarlyGO — all one-click, reversible, before/after.
|
||||||
- **GrammaGem 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.
|
- **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:**
|
- **Do:**
|
||||||
- Wire the **real MLX model** from `ModelManager.modelsDirectory` (the download is currently a dead-end); uncomment `mlx-swift` in `Package.swift`.
|
- 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."
|
- Adopt the **one-click + reversible + before/after diff** delivery contract for every rewrite — this is what neutralizes "the AI is taking over my voice."
|
||||||
@ -133,7 +133,7 @@ Wire dismiss / add-to-dictionary (Mechanic 2) back into ranking so repeated fals
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. The first-run "wow" sequence for GrammaGem (original copy)
|
## 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.
|
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.
|
||||||
|
|
||||||
@ -145,7 +145,7 @@ Proposed flow:
|
|||||||
|
|
||||||
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.)
|
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 GrammaGem. 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."
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ A thin Rust static library that exposes the **Harper** grammar core
|
|||||||
integration (spec §1, §2, §8).
|
integration (spec §1, §2, §8).
|
||||||
|
|
||||||
The Swift side talks to this through the `GrammarEngine` protocol; today
|
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.
|
small pure-Swift rule stub so the system loop + tests run with zero native deps.
|
||||||
|
|
||||||
## Planned shape
|
## Planned shape
|
||||||
|
|||||||
@ -15,7 +15,7 @@ cd "$ROOT"
|
|||||||
DD=".xcode-build"
|
DD=".xcode-build"
|
||||||
|
|
||||||
echo "==> xcodebuild (compiles mlx-swift metal kernels — a few minutes)"
|
echo "==> xcodebuild (compiles mlx-swift metal kernels — a few minutes)"
|
||||||
xcodebuild build -scheme GrammaGem -configuration Release \
|
xcodebuild build -scheme GrammarGem -configuration Release \
|
||||||
-destination 'platform=macOS,arch=arm64' \
|
-destination 'platform=macOS,arch=arm64' \
|
||||||
-derivedDataPath "$DD" -skipMacroValidation >/dev/null
|
-derivedDataPath "$DD" -skipMacroValidation >/dev/null
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Build a runnable GrammaGem.app bundle (arm64 / Apple Silicon — the MLX
|
# Build a runnable GrammarGem.app bundle (arm64 / Apple Silicon — the MLX
|
||||||
# on-device LLM runtime is Metal-only, so Intel is not supported).
|
# on-device LLM runtime is Metal-only, so Intel is not supported).
|
||||||
# Usage: ./scripts/build.sh (run from the mac/ directory)
|
# Usage: ./scripts/build.sh (run from the mac/ directory)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@ -8,7 +8,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
CONFIG="release"
|
CONFIG="release"
|
||||||
APP="GrammaGem.app"
|
APP="GrammarGem.app"
|
||||||
DIST="dist"
|
DIST="dist"
|
||||||
|
|
||||||
echo "==> Building Harper static lib (universal)"
|
echo "==> Building Harper static lib (universal)"
|
||||||
@ -24,7 +24,7 @@ echo "==> Assembling ${APP}"
|
|||||||
rm -rf "${DIST}/${APP}"
|
rm -rf "${DIST}/${APP}"
|
||||||
mkdir -p "${DIST}/${APP}/Contents/MacOS" "${DIST}/${APP}/Contents/Resources"
|
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/Info.plist" "${DIST}/${APP}/Contents/Info.plist"
|
||||||
cp "AppSupport/AppIcon.icns" "${DIST}/${APP}/Contents/Resources/AppIcon.icns"
|
cp "AppSupport/AppIcon.icns" "${DIST}/${APP}/Contents/Resources/AppIcon.icns"
|
||||||
|
|
||||||
@ -52,8 +52,8 @@ SPARKLE_FW="$(find .build -path '*Sparkle.xcframework/macos-arm64*/Sparkle.frame
|
|||||||
mkdir -p "${DIST}/${APP}/Contents/Frameworks"
|
mkdir -p "${DIST}/${APP}/Contents/Frameworks"
|
||||||
cp -R "${SPARKLE_FW}" "${DIST}/${APP}/Contents/Frameworks/"
|
cp -R "${SPARKLE_FW}" "${DIST}/${APP}/Contents/Frameworks/"
|
||||||
install_name_tool -add_rpath "@executable_path/../Frameworks" \
|
install_name_tool -add_rpath "@executable_path/../Frameworks" \
|
||||||
"${DIST}/${APP}/Contents/MacOS/GrammaGem" 2>/dev/null || true
|
"${DIST}/${APP}/Contents/MacOS/GrammarGem" 2>/dev/null || true
|
||||||
|
|
||||||
echo "==> Built ${DIST}/${APP}"
|
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)"
|
echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)"
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env swift
|
#!/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
|
// brand square. Run from mac/: swift scripts/make-icon.swift
|
||||||
// Then: iconutil -c icns AppIcon.iconset -o AppSupport/AppIcon.icns
|
// Then: iconutil -c icns AppIcon.iconset -o AppSupport/AppIcon.icns
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Cut a GrammaGem release and publish the Sparkle appcast (hosted on grammagem.app).
|
# Cut a GrammarGem release and publish the Sparkle appcast (hosted on grammargem.com).
|
||||||
#
|
#
|
||||||
# ./scripts/release.sh <version> e.g. ./scripts/release.sh 0.2.0
|
# ./scripts/release.sh <version> e.g. ./scripts/release.sh 0.2.0
|
||||||
#
|
#
|
||||||
# Steps: stamp version -> build -> sign inside-out (incl. Sparkle) -> notarize +
|
# Steps: stamp version -> build -> sign inside-out (incl. Sparkle) -> notarize +
|
||||||
# staple -> zip -> generate the EdDSA-signed appcast.xml. Then upload the .zip and
|
# staple -> zip -> generate the EdDSA-signed appcast.xml. Then upload the .zip and
|
||||||
# appcast.xml to grammagem.app so every installed app shows "Update available".
|
# appcast.xml to grammargem.com so every installed app shows "Update available".
|
||||||
#
|
#
|
||||||
# Notarization needs ONE of:
|
# Notarization needs ONE of:
|
||||||
# NOTARY_PROFILE (xcrun notarytool store-credentials)
|
# NOTARY_PROFILE (xcrun notarytool store-credentials)
|
||||||
@ -17,10 +17,10 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
VERSION="${1:?usage: release.sh <version> e.g. release.sh 0.2.0}"
|
VERSION="${1:?usage: release.sh <version> e.g. release.sh 0.2.0}"
|
||||||
APP="dist/GrammaGem.app"
|
APP="dist/GrammarGem.app"
|
||||||
RELDIR="dist/releases"
|
RELDIR="dist/releases"
|
||||||
ZIP="${RELDIR}/GrammaGem-${VERSION}.zip"
|
ZIP="${RELDIR}/GrammarGem-${VERSION}.zip"
|
||||||
DL_PREFIX="${DL_PREFIX:-https://grammagem.app/releases/}"
|
DL_PREFIX="${DL_PREFIX:-https://www.grammargem.com/releases/}"
|
||||||
PB=/usr/libexec/PlistBuddy
|
PB=/usr/libexec/PlistBuddy
|
||||||
|
|
||||||
# 1. Stamp the marketing version + bump the monotonic build number.
|
# 1. Stamp the marketing version + bump the monotonic build number.
|
||||||
@ -61,11 +61,11 @@ GA="$(find .build/artifacts -name generate_appcast -type f | head -1)"
|
|||||||
"$GA" --download-url-prefix "$DL_PREFIX" "$RELDIR"
|
"$GA" --download-url-prefix "$DL_PREFIX" "$RELDIR"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "✓ Release ${VERSION} built. Upload these to grammagem.app:"
|
echo "✓ Release ${VERSION} built. Upload these to grammargem.com:"
|
||||||
echo " ${ZIP}"
|
echo " ${ZIP}"
|
||||||
echo " -> ${DL_PREFIX}GrammaGem-${VERSION}.zip"
|
echo " -> ${DL_PREFIX}GrammarGem-${VERSION}.zip"
|
||||||
echo " ${RELDIR}/appcast.xml"
|
echo " ${RELDIR}/appcast.xml"
|
||||||
echo " -> https://grammagem.app/appcast.xml (matches Info.plist SUFeedURL)"
|
echo " -> https://www.grammargem.com/appcast.xml (matches Info.plist SUFeedURL)"
|
||||||
echo
|
echo
|
||||||
echo " Once uploaded, installed apps show an 'Update available' button within a day"
|
echo " Once uploaded, installed apps show an 'Update available' button within a day"
|
||||||
echo " (or immediately via menu bar -> Check for Updates...)."
|
echo " (or immediately via menu bar -> Check for Updates...)."
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Code-sign GrammaGem.app with Developer ID + Hardened Runtime, signing the
|
# Code-sign GrammarGem.app with Developer ID + Hardened Runtime, signing the
|
||||||
# embedded Sparkle.framework's nested helpers INSIDE-OUT first (required for a
|
# embedded Sparkle.framework's nested helpers INSIDE-OUT first (required for a
|
||||||
# valid, notarizable signature).
|
# valid, notarizable signature).
|
||||||
#
|
#
|
||||||
# Usage: ./scripts/sign-app.sh [path/to/GrammaGem.app]
|
# Usage: ./scripts/sign-app.sh [path/to/GrammarGem.app]
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
DEV_ID="${DEV_ID:-Developer ID Application: Marc Schultheiss (D5KT5B9Z9M)}"
|
DEV_ID="${DEV_ID:-Developer ID Application: Marc Schultheiss (D5KT5B9Z9M)}"
|
||||||
ENTITLEMENTS="AppSupport/GrammaGem.entitlements"
|
ENTITLEMENTS="AppSupport/GrammarGem.entitlements"
|
||||||
APP="${1:-dist/GrammaGem.app}"
|
APP="${1:-dist/GrammarGem.app}"
|
||||||
|
|
||||||
sign() { codesign --force --timestamp --options runtime --sign "$DEV_ID" "$@"; }
|
sign() { codesign --force --timestamp --options runtime --sign "$DEV_ID" "$@"; }
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Build (universal), codesign (Hardened Runtime + Developer ID), notarize, and
|
# 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.
|
# cost, not per-user.
|
||||||
#
|
#
|
||||||
# Credentials are read from the environment or a stored keychain profile — never
|
# Credentials are read from the environment or a stored keychain profile — never
|
||||||
@ -15,9 +15,9 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
DEV_ID="${DEV_ID:-Developer ID Application: Marc Schultheiss (D5KT5B9Z9M)}"
|
DEV_ID="${DEV_ID:-Developer ID Application: Marc Schultheiss (D5KT5B9Z9M)}"
|
||||||
APP="dist/GrammaGem.app"
|
APP="dist/GrammarGem.app"
|
||||||
ZIP="dist/GrammaGem.zip"
|
ZIP="dist/GrammarGem.zip"
|
||||||
ENTITLEMENTS="AppSupport/GrammaGem.entitlements"
|
ENTITLEMENTS="AppSupport/GrammarGem.entitlements"
|
||||||
|
|
||||||
echo "==> Building universal bundle"
|
echo "==> Building universal bundle"
|
||||||
./scripts/build.sh >/dev/null
|
./scripts/build.sh >/dev/null
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user