diff --git a/AppSupport/Info.plist b/AppSupport/Info.plist
index 8b4ef3c..9cf2470 100644
--- a/AppSupport/Info.plist
+++ b/AppSupport/Info.plist
@@ -43,5 +43,19 @@
© 2026 FounderGem LLC. Built in public.
NSPrincipalClass
NSApplication
+
+
+ SUFeedURL
+ https://grammagem.app/appcast.xml
+ SUPublicEDKey
+ IwYch+DRgNk3gmCwe86z+SIc4sRdyDf4Z6xGpS+JXDc=
+ SUEnableAutomaticChecks
+
+ SUScheduledCheckInterval
+ 86400
+ SUAutomaticallyUpdate
+
diff --git a/Package.resolved b/Package.resolved
index 1197b37..2e0f4f3 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "1aab35282a23815fc7c2ac7484414660e78b19f68cfbf14f87004cf55bb3ac6b",
+ "originHash" : "f78005d8f2f9a6a31d5c42cc7ce2b46907eb92fce74810060a58056e592ea3a1",
"pins" : [
{
"identity" : "gzipswift",
@@ -28,6 +28,15 @@
"version" : "2.25.9"
}
},
+ {
+ "identity" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle",
+ "state" : {
+ "revision" : "d46d456107feacc80711b21847b82b07bd9fb46e",
+ "version" : "2.9.3"
+ }
+ },
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
diff --git a/Package.swift b/Package.swift
index 04ed911..4eb49a7 100644
--- a/Package.swift
+++ b/Package.swift
@@ -29,6 +29,8 @@ let package = Package(
// On-device LLM runtime. Pinned to an exact tag (the LLM libraries still
// ship from this repo at 2.25.9; the package name is "mlx-libraries").
.package(url: "https://github.com/ml-explore/mlx-swift-examples.git", exact: "2.25.9"),
+ // Secure auto-updates (EdDSA-signed appcast served from grammagem.app).
+ .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"),
],
targets: [
// C shim exposing libharper_ffi's C ABI (harper-ffi/include/harper.h) to Swift.
@@ -39,6 +41,7 @@ let package = Package(
"CHarper",
.product(name: "MLXLLM", package: "mlx-swift-examples"),
.product(name: "MLXLMCommon", package: "mlx-swift-examples"),
+ .product(name: "Sparkle", package: "Sparkle"),
],
path: "Sources/GrammaGem",
linkerSettings: [
diff --git a/Sources/GrammaGem/App/AppState.swift b/Sources/GrammaGem/App/AppState.swift
index 07e3b93..66d0bb0 100644
--- a/Sources/GrammaGem/App/AppState.swift
+++ b/Sources/GrammaGem/App/AppState.swift
@@ -27,6 +27,7 @@ final class AppState: ObservableObject {
let snippets: SnippetStore
let exclusions: Exclusions
let liveMonitor: LiveMonitor
+ let updater: UpdaterManager
private let capture: TextCapture
let coordinator: TextReplacementCoordinator
@@ -65,6 +66,7 @@ final class AppState: ObservableObject {
let excl = Exclusions()
exclusions = excl
liveMonitor = LiveMonitor(grammar: engine, detector: detector, exclusions: excl, capture: capture)
+ updater = UpdaterManager()
coordinator = TextReplacementCoordinator(
capture: capture, grammar: engine, ai: ai, gate: gate, detector: detector)
}
diff --git a/Sources/GrammaGem/App/GrammaGemApp.swift b/Sources/GrammaGem/App/GrammaGemApp.swift
index 61de6b0..2f0031e 100644
--- a/Sources/GrammaGem/App/GrammaGemApp.swift
+++ b/Sources/GrammaGem/App/GrammaGemApp.swift
@@ -11,6 +11,11 @@ enum GrammaGemMain {
MLXSelfTest.run()
return
}
+ if let i = CommandLine.arguments.firstIndex(of: "--update-selftest") {
+ let feed = i + 1 < CommandLine.arguments.count ? CommandLine.arguments[i + 1] : ""
+ UpdaterSelfTest.run(feedURL: feed)
+ return
+ }
GrammaGemApp.main()
}
}
diff --git a/Sources/GrammaGem/App/MenuBarContent.swift b/Sources/GrammaGem/App/MenuBarContent.swift
index dc78fc9..053e3dd 100644
--- a/Sources/GrammaGem/App/MenuBarContent.swift
+++ b/Sources/GrammaGem/App/MenuBarContent.swift
@@ -24,16 +24,22 @@ struct MenuBarLabel: View {
struct MenuBarContent: View {
@EnvironmentObject private var app: AppState
@ObservedObject private var monitor: LiveMonitor
+ @ObservedObject private var updater: UpdaterManager
@Environment(\.openSettings) private var openSettings
init() {
_monitor = ObservedObject(wrappedValue: AppState.shared.liveMonitor)
+ _updater = ObservedObject(wrappedValue: AppState.shared.updater)
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
header
+ if let version = updater.updateAvailableVersion {
+ updateBanner(version: version)
+ }
+
Divider()
liveStatus
@@ -61,6 +67,7 @@ struct MenuBarContent: View {
actionRow(title: "Open GrammaGem…", shortcut: nil) { app.showMainWindow() }
actionRow(title: "Manage devices…", shortcut: nil) { app.showMainWindow(select: .devices) }
actionRow(title: "Page blocker…", shortcut: nil) { app.showMainWindow(select: .exclusions) }
+ actionRow(title: "Check for Updates…", shortcut: nil) { app.updater.checkForUpdates() }
Divider()
@@ -83,6 +90,22 @@ struct MenuBarContent: View {
.frame(width: 290)
}
+ /// Shown when Sparkle has found a newer signed build on grammagem.app.
+ private func updateBanner(version: String) -> some View {
+ HStack(spacing: 8) {
+ Image(systemName: "arrow.down.circle.fill").foregroundStyle(GG.emerald)
+ VStack(alignment: .leading, spacing: 1) {
+ Text("Update available").font(.callout.weight(.semibold))
+ Text("Version \(version) is ready to install").font(.caption).foregroundStyle(.secondary)
+ }
+ Spacer()
+ Button("Update") { app.updater.checkForUpdates() }
+ .buttonStyle(.borderedProminent).controlSize(.small)
+ }
+ .padding(8)
+ .background(GG.emerald.opacity(0.10), in: RoundedRectangle(cornerRadius: 8))
+ }
+
private var header: some View {
HStack(spacing: 8) {
Image(systemName: "pencil.and.scribble").foregroundStyle(.tint)
diff --git a/Sources/GrammaGem/System/UpdaterManager.swift b/Sources/GrammaGem/System/UpdaterManager.swift
new file mode 100644
index 0000000..c8aac65
--- /dev/null
+++ b/Sources/GrammaGem/System/UpdaterManager.swift
@@ -0,0 +1,81 @@
+import Foundation
+import Sparkle
+
+/// Wraps Sparkle's updater. It background-checks the appcast on grammagem.app and
+/// publishes `updateAvailableVersion` when a newer, EdDSA-verified build exists, so
+/// the menu bar can show an "Update available" button. Triggering an update runs
+/// Sparkle's verified download + install flow.
+///
+/// The updater is only started inside the packaged `.app` (where the Info.plist
+/// carries `SUFeedURL` + `SUPublicEDKey`); under `swift run`, tests, or the
+/// self-test binaries it stays inert so nothing crashes.
+@MainActor
+final class UpdaterManager: NSObject, ObservableObject {
+ @Published private(set) var updateAvailableVersion: String?
+
+ private var controller: SPUStandardUpdaterController?
+
+ override init() {
+ super.init()
+ guard Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") != nil else {
+ return // not the packaged app — no updater
+ }
+ controller = SPUStandardUpdaterController(
+ startingUpdater: true, updaterDelegate: self, userDriverDelegate: nil)
+ }
+
+ var canCheckForUpdates: Bool { controller?.updater.canCheckForUpdates ?? false }
+
+ /// User-initiated check — shows Sparkle's UI (used by the menu item and the
+ /// "Update" button on the availability banner).
+ func checkForUpdates() {
+ controller?.updater.checkForUpdates()
+ }
+}
+
+extension UpdaterManager: SPUUpdaterDelegate {
+ func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
+ updateAvailableVersion = item.displayVersionString
+ }
+
+ func updaterDidNotFindUpdate(_ updater: SPUUpdater) {
+ updateAvailableVersion = nil
+ }
+}
+
+// MARK: - Headless update self-test (build pipeline / CI)
+
+/// Checks `feedURL` for a newer version with no UI and prints the result, then
+/// exits. Used to verify the appcast + signature + version-comparison path.
+enum UpdaterSelfTest {
+ static func run(feedURL: String) {
+ let delegate = UpdateProbeDelegate(feed: feedURL)
+ UpdateProbeDelegate.retained = delegate
+ UpdateProbeDelegate.controller = SPUStandardUpdaterController(
+ startingUpdater: true, updaterDelegate: delegate, userDriverDelegate: nil)
+ UpdateProbeDelegate.controller?.updater.checkForUpdateInformation()
+ RunLoop.main.run() // Sparkle's network check needs the run loop; delegate exits.
+ }
+}
+
+private final class UpdateProbeDelegate: NSObject, SPUUpdaterDelegate {
+ static var controller: SPUStandardUpdaterController?
+ static var retained: UpdateProbeDelegate?
+ let feed: String
+ init(feed: String) { self.feed = feed; super.init() }
+
+ func feedURLString(for updater: SPUUpdater) -> String? { feed.isEmpty ? nil : feed }
+
+ func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
+ print("UPDATE_FOUND: \(item.displayVersionString)")
+ exit(0)
+ }
+ func updaterDidNotFindUpdate(_ updater: SPUUpdater) {
+ print("UPDATE_NONE")
+ exit(0)
+ }
+ func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
+ print("UPDATE_ERROR: \(error.localizedDescription)")
+ exit(1)
+ }
+}
diff --git a/scripts/build.sh b/scripts/build.sh
index 62aa160..e530154 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -45,6 +45,15 @@ MLX_RES="${DIST}/${APP}/Contents/Resources/mlx-swift_Cmlx.bundle/Contents/Resour
mkdir -p "${MLX_RES}"
cp "AppSupport/mlx.metallib" "${MLX_RES}/default.metallib"
+# Embed Sparkle.framework (auto-update) and add an rpath so the executable can
+# resolve it at @executable_path/../Frameworks. (Signed inside-out by sign-app.sh.)
+echo "==> Embedding Sparkle.framework"
+SPARKLE_FW="$(find .build -path '*Sparkle.xcframework/macos-arm64*/Sparkle.framework' -type d | head -1)"
+mkdir -p "${DIST}/${APP}/Contents/Frameworks"
+cp -R "${SPARKLE_FW}" "${DIST}/${APP}/Contents/Frameworks/"
+install_name_tool -add_rpath "@executable_path/../Frameworks" \
+ "${DIST}/${APP}/Contents/MacOS/GrammaGem" 2>/dev/null || true
+
echo "==> Built ${DIST}/${APP}"
lipo -info "${DIST}/${APP}/Contents/MacOS/GrammaGem"
echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)"
diff --git a/scripts/release.sh b/scripts/release.sh
index db7a335..5699081 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -1,32 +1,71 @@
#!/usr/bin/env bash
-# Package a signed build for distribution and update the Sparkle appcast hosted
-# on GitHub Releases (zero hosting cost). Run after sign-notarize.sh.
+# Cut a GrammaGem release and publish the Sparkle appcast (hosted on grammagem.app).
#
-# This is a documented scaffold — wire it to your Sparkle EdDSA key + repo.
+# ./scripts/release.sh e.g. ./scripts/release.sh 0.2.0
+#
+# Steps: stamp version -> build -> sign inside-out (incl. Sparkle) -> notarize +
+# staple -> zip -> generate the EdDSA-signed appcast.xml. Then upload the .zip and
+# appcast.xml to grammagem.app so every installed app shows "Update available".
+#
+# Notarization needs ONE of:
+# NOTARY_PROFILE (xcrun notarytool store-credentials)
+# APPLE_ID + TEAM_ID + NOTARY_PASSWORD (Apple ID + app-specific password)
+# Set SKIP_NOTARIZE=1 for a dry run (un-notarized build; do not ship).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
-APP="$ROOT/dist/GrammaGem.app"
-VERSION="${1:?Usage: release.sh e.g. 0.1.0}"
-DMG="$ROOT/dist/GrammaGem-$VERSION.dmg"
+cd "$ROOT"
-echo "▸ Creating DMG for $VERSION…"
-# hdiutil create -volname "GrammaGem" -srcfolder "$APP" -ov -format UDZO "$DMG" # TODO: enable
-echo " (DMG creation stubbed — uncomment hdiutil line)"
+VERSION="${1:?usage: release.sh e.g. release.sh 0.2.0}"
+APP="dist/GrammaGem.app"
+RELDIR="dist/releases"
+ZIP="${RELDIR}/GrammaGem-${VERSION}.zip"
+DL_PREFIX="${DL_PREFIX:-https://grammagem.app/releases/}"
+PB=/usr/libexec/PlistBuddy
-cat <<'NOTE'
-▸ Sparkle appcast (TODO):
- 1. Sign the update: ./bin/sign_update dist/GrammaGem-.dmg
- (Sparkle's sign_update tool, using your EdDSA private key)
- 2. Add an - to appcast.xml with the version, URL (GitHub Releases asset),
- length, and sparkle:edSignature.
- 3. Upload the DMG + appcast.xml to a GitHub Release.
- The app's SUFeedURL points at the raw appcast.xml on GitHub — $0 hosting.
+# 1. Stamp the marketing version + bump the monotonic build number.
+CUR_BUILD="$($PB -c 'Print :CFBundleVersion' AppSupport/Info.plist)"
+NEW_BUILD=$((CUR_BUILD + 1))
+$PB -c "Set :CFBundleShortVersionString ${VERSION}" AppSupport/Info.plist
+$PB -c "Set :CFBundleVersion ${NEW_BUILD}" AppSupport/Info.plist
+echo "==> Releasing ${VERSION} (build ${NEW_BUILD})"
-▸ Distribution touchpoints (all $0 marginal cost):
- - Model weights: Hugging Face CDN
- - Updates: GitHub Releases + Sparkle
- - Licensing: Lemon Squeezy License API (% per sale only)
-NOTE
+# 2. Build + sign (inside-out so the embedded Sparkle.framework is valid).
+./scripts/build.sh
+./scripts/sign-app.sh "$APP"
-echo "✓ Release steps printed for $VERSION"
+# 3. Zip.
+mkdir -p "$RELDIR"
+rm -f "$ZIP"
+ditto -c -k --keepParent "$APP" "$ZIP"
+
+# 4. Notarize + staple (the ticket lets Sparkle install it without Gatekeeper prompts).
+if [ "${SKIP_NOTARIZE:-0}" != "1" ]; then
+ echo "==> Notarizing"
+ if [ -n "${NOTARY_PROFILE:-}" ]; then
+ xcrun notarytool submit "$ZIP" --keychain-profile "$NOTARY_PROFILE" --wait
+ elif [ -n "${APPLE_ID:-}" ] && [ -n "${TEAM_ID:-}" ] && [ -n "${NOTARY_PASSWORD:-}" ]; then
+ xcrun notarytool submit "$ZIP" \
+ --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$NOTARY_PASSWORD" --wait
+ else
+ echo "ERROR: set NOTARY_PROFILE or APPLE_ID+TEAM_ID+NOTARY_PASSWORD (or SKIP_NOTARIZE=1)." >&2
+ exit 1
+ fi
+ xcrun stapler staple "$APP"
+ rm -f "$ZIP"; ditto -c -k --keepParent "$APP" "$ZIP" # re-zip the stapled app
+fi
+
+# 5. Generate the EdDSA-signed appcast (keeps every zip in RELDIR in its history).
+GA="$(find .build/artifacts -name generate_appcast -type f | head -1)"
+[ -n "$GA" ] || { echo "ERROR: generate_appcast not found (run 'swift package resolve')." >&2; exit 1; }
+"$GA" --download-url-prefix "$DL_PREFIX" "$RELDIR"
+
+echo
+echo "✓ Release ${VERSION} built. Upload these to grammagem.app:"
+echo " ${ZIP}"
+echo " -> ${DL_PREFIX}GrammaGem-${VERSION}.zip"
+echo " ${RELDIR}/appcast.xml"
+echo " -> https://grammagem.app/appcast.xml (matches Info.plist SUFeedURL)"
+echo
+echo " Once uploaded, installed apps show an 'Update available' button within a day"
+echo " (or immediately via menu bar -> Check for Updates...)."
diff --git a/scripts/sign-app.sh b/scripts/sign-app.sh
new file mode 100755
index 0000000..869f88a
--- /dev/null
+++ b/scripts/sign-app.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# Code-sign GrammaGem.app with Developer ID + Hardened Runtime, signing the
+# embedded Sparkle.framework's nested helpers INSIDE-OUT first (required for a
+# valid, notarizable signature).
+#
+# Usage: ./scripts/sign-app.sh [path/to/GrammaGem.app]
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+DEV_ID="${DEV_ID:-Developer ID Application: Marc Schultheiss (D5KT5B9Z9M)}"
+ENTITLEMENTS="AppSupport/GrammaGem.entitlements"
+APP="${1:-dist/GrammaGem.app}"
+
+sign() { codesign --force --timestamp --options runtime --sign "$DEV_ID" "$@"; }
+
+FW="${APP}/Contents/Frameworks/Sparkle.framework"
+if [ -d "$FW" ]; then
+ echo "==> Signing Sparkle.framework (inside-out)"
+ V="${FW}/Versions/B"
+ for xpc in "$V"/XPCServices/*.xpc; do [ -e "$xpc" ] && sign "$xpc"; done
+ [ -e "$V/Updater.app/Contents/MacOS/Autoupdate" ] && sign "$V/Updater.app/Contents/MacOS/Autoupdate" || true
+ [ -e "$V/Updater.app" ] && sign "$V/Updater.app"
+ [ -e "$V/Autoupdate" ] && sign "$V/Autoupdate"
+ sign "$FW"
+fi
+
+echo "==> Signing ${APP}"
+codesign --force --timestamp --options runtime \
+ --entitlements "$ENTITLEMENTS" --sign "$DEV_ID" "$APP"
+
+echo "==> Verifying"
+codesign --verify --strict --verbose=2 "$APP"
+echo "✓ signed: $APP"
diff --git a/scripts/sign-notarize.sh b/scripts/sign-notarize.sh
index be65729..a5d58cc 100755
--- a/scripts/sign-notarize.sh
+++ b/scripts/sign-notarize.sh
@@ -22,12 +22,8 @@ ENTITLEMENTS="AppSupport/GrammaGem.entitlements"
echo "==> Building universal bundle"
./scripts/build.sh >/dev/null
-echo "==> Codesigning (Hardened Runtime)"
-codesign --force --timestamp --options runtime \
- --entitlements "$ENTITLEMENTS" \
- --sign "$DEV_ID" \
- "$APP"
-codesign --verify --strict --verbose=2 "$APP"
+echo "==> Codesigning (Hardened Runtime, inside-out incl. Sparkle)"
+DEV_ID="$DEV_ID" ./scripts/sign-app.sh "$APP"
echo "==> Zipping"
ditto -c -k --keepParent "$APP" "$ZIP"