Add Sparkle auto-update (appcast on grammagem.app)

Installed apps now show an 'Update available' button (menu bar) and can
one-click install signed updates. Adds the Sparkle dependency, an
UpdaterManager wired into AppState, the menu-bar banner + 'Check for
Updates', and Info.plist SUFeedURL (https://grammagem.app/appcast.xml),
SUPublicEDKey, and scheduled background checks.

Build / release:
- build.sh embeds + rpaths Sparkle.framework; sign-app.sh signs it
  inside-out (XPC services, Updater.app, Autoupdate, framework, then app)
  for a valid, notarizable signature.
- release.sh stamps the version, builds, signs, notarizes, staples, zips,
  and emits the EdDSA-signed appcast.xml to upload to grammagem.app.
- A hidden --update-selftest checks a feed headlessly (CI/build pipeline).

Verified: a v0.1.0 build detects a v0.2.0 update against a local signed
appcast (UPDATE_FOUND: 0.2.0); the signed app passes codesign --strict.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BigMarc 2026-06-18 08:22:35 +02:00
parent 8a9ffb55af
commit e8b7dff65d
11 changed files with 246 additions and 30 deletions

View File

@ -43,5 +43,19 @@
<string>© 2026 FounderGem LLC. Built in public.</string> <string>© 2026 FounderGem LLC. Built in public.</string>
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string>NSApplication</string> <string>NSApplication</string>
<!-- Sparkle auto-update. Appcast is served from grammagem.app and updates are
EdDSA-signed with the key whose public half is below (private key lives in
the release machine's keychain; sign with scripts/release.sh). -->
<key>SUFeedURL</key>
<string>https://grammagem.app/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>IwYch+DRgNk3gmCwe86z+SIc4sRdyDf4Z6xGpS+JXDc=</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUScheduledCheckInterval</key>
<integer>86400</integer>
<key>SUAutomaticallyUpdate</key>
<false/>
</dict> </dict>
</plist> </plist>

View File

@ -1,5 +1,5 @@
{ {
"originHash" : "1aab35282a23815fc7c2ac7484414660e78b19f68cfbf14f87004cf55bb3ac6b", "originHash" : "f78005d8f2f9a6a31d5c42cc7ce2b46907eb92fce74810060a58056e592ea3a1",
"pins" : [ "pins" : [
{ {
"identity" : "gzipswift", "identity" : "gzipswift",
@ -28,6 +28,15 @@
"version" : "2.25.9" "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", "identity" : "swift-collections",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View File

@ -29,6 +29,8 @@ let package = Package(
// 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).
.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.
@ -39,6 +41,7 @@ let package = Package(
"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"),
], ],
path: "Sources/GrammaGem", path: "Sources/GrammaGem",
linkerSettings: [ linkerSettings: [

View File

@ -27,6 +27,7 @@ final class AppState: ObservableObject {
let snippets: SnippetStore let snippets: SnippetStore
let exclusions: Exclusions let exclusions: Exclusions
let liveMonitor: LiveMonitor let liveMonitor: LiveMonitor
let updater: UpdaterManager
private let capture: TextCapture private let capture: TextCapture
let coordinator: TextReplacementCoordinator let coordinator: TextReplacementCoordinator
@ -65,6 +66,7 @@ final class AppState: ObservableObject {
let excl = Exclusions() let excl = Exclusions()
exclusions = excl exclusions = excl
liveMonitor = LiveMonitor(grammar: engine, detector: detector, exclusions: excl, capture: capture) liveMonitor = LiveMonitor(grammar: engine, detector: detector, exclusions: excl, capture: capture)
updater = UpdaterManager()
coordinator = TextReplacementCoordinator( coordinator = TextReplacementCoordinator(
capture: capture, grammar: engine, ai: ai, gate: gate, detector: detector) capture: capture, grammar: engine, ai: ai, gate: gate, detector: detector)
} }

View File

@ -11,6 +11,11 @@ enum GrammaGemMain {
MLXSelfTest.run() MLXSelfTest.run()
return 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() GrammaGemApp.main()
} }
} }

View File

@ -24,16 +24,22 @@ struct MenuBarLabel: View {
struct MenuBarContent: View { struct MenuBarContent: View {
@EnvironmentObject private var app: AppState @EnvironmentObject private var app: AppState
@ObservedObject private var monitor: LiveMonitor @ObservedObject private var monitor: LiveMonitor
@ObservedObject private var updater: UpdaterManager
@Environment(\.openSettings) private var openSettings @Environment(\.openSettings) private var openSettings
init() { init() {
_monitor = ObservedObject(wrappedValue: AppState.shared.liveMonitor) _monitor = ObservedObject(wrappedValue: AppState.shared.liveMonitor)
_updater = ObservedObject(wrappedValue: AppState.shared.updater)
} }
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
header header
if let version = updater.updateAvailableVersion {
updateBanner(version: version)
}
Divider() Divider()
liveStatus liveStatus
@ -61,6 +67,7 @@ struct MenuBarContent: View {
actionRow(title: "Open GrammaGem…", shortcut: nil) { app.showMainWindow() } actionRow(title: "Open GrammaGem…", 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() }
Divider() Divider()
@ -83,6 +90,22 @@ struct MenuBarContent: View {
.frame(width: 290) .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 { private var header: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "pencil.and.scribble").foregroundStyle(.tint) Image(systemName: "pencil.and.scribble").foregroundStyle(.tint)

View File

@ -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)
}
}

View File

@ -45,6 +45,15 @@ MLX_RES="${DIST}/${APP}/Contents/Resources/mlx-swift_Cmlx.bundle/Contents/Resour
mkdir -p "${MLX_RES}" mkdir -p "${MLX_RES}"
cp "AppSupport/mlx.metallib" "${MLX_RES}/default.metallib" 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}" echo "==> Built ${DIST}/${APP}"
lipo -info "${DIST}/${APP}/Contents/MacOS/GrammaGem" lipo -info "${DIST}/${APP}/Contents/MacOS/GrammaGem"
echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)" echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)"

View File

@ -1,32 +1,71 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Package a signed build for distribution and update the Sparkle appcast hosted # Cut a GrammaGem release and publish the Sparkle appcast (hosted on grammagem.app).
# on GitHub Releases (zero hosting cost). Run after sign-notarize.sh.
# #
# This is a documented scaffold — wire it to your Sparkle EdDSA key + repo. # ./scripts/release.sh <version> 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 set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
APP="$ROOT/dist/GrammaGem.app" cd "$ROOT"
VERSION="${1:?Usage: release.sh <version> e.g. 0.1.0}"
DMG="$ROOT/dist/GrammaGem-$VERSION.dmg"
echo "▸ Creating DMG for $VERSION…" VERSION="${1:?usage: release.sh <version> e.g. release.sh 0.2.0}"
# hdiutil create -volname "GrammaGem" -srcfolder "$APP" -ov -format UDZO "$DMG" # TODO: enable APP="dist/GrammaGem.app"
echo " (DMG creation stubbed — uncomment hdiutil line)" RELDIR="dist/releases"
ZIP="${RELDIR}/GrammaGem-${VERSION}.zip"
DL_PREFIX="${DL_PREFIX:-https://grammagem.app/releases/}"
PB=/usr/libexec/PlistBuddy
cat <<'NOTE' # 1. Stamp the marketing version + bump the monotonic build number.
▸ Sparkle appcast (TODO): CUR_BUILD="$($PB -c 'Print :CFBundleVersion' AppSupport/Info.plist)"
1. Sign the update: ./bin/sign_update dist/GrammaGem-<ver>.dmg NEW_BUILD=$((CUR_BUILD + 1))
(Sparkle's sign_update tool, using your EdDSA private key) $PB -c "Set :CFBundleShortVersionString ${VERSION}" AppSupport/Info.plist
2. Add an <item> to appcast.xml with the version, URL (GitHub Releases asset), $PB -c "Set :CFBundleVersion ${NEW_BUILD}" AppSupport/Info.plist
length, and sparkle:edSignature. echo "==> Releasing ${VERSION} (build ${NEW_BUILD})"
3. Upload the DMG + appcast.xml to a GitHub Release.
The app's SUFeedURL points at the raw appcast.xml on GitHub — $0 hosting.
▸ Distribution touchpoints (all $0 marginal cost): # 2. Build + sign (inside-out so the embedded Sparkle.framework is valid).
- Model weights: Hugging Face CDN ./scripts/build.sh
- Updates: GitHub Releases + Sparkle ./scripts/sign-app.sh "$APP"
- Licensing: Lemon Squeezy License API (% per sale only)
NOTE
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...)."

35
scripts/sign-app.sh Executable file
View File

@ -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"

View File

@ -22,12 +22,8 @@ ENTITLEMENTS="AppSupport/GrammaGem.entitlements"
echo "==> Building universal bundle" echo "==> Building universal bundle"
./scripts/build.sh >/dev/null ./scripts/build.sh >/dev/null
echo "==> Codesigning (Hardened Runtime)" echo "==> Codesigning (Hardened Runtime, inside-out incl. Sparkle)"
codesign --force --timestamp --options runtime \ DEV_ID="$DEV_ID" ./scripts/sign-app.sh "$APP"
--entitlements "$ENTITLEMENTS" \
--sign "$DEV_ID" \
"$APP"
codesign --verify --strict --verbose=2 "$APP"
echo "==> Zipping" echo "==> Zipping"
ditto -c -k --keepParent "$APP" "$ZIP" ditto -c -k --keepParent "$APP" "$ZIP"