mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
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:
parent
8a9ffb55af
commit
e8b7dff65d
@ -43,5 +43,19 @@
|
||||
<string>© 2026 FounderGem LLC. Built in public.</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<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>
|
||||
</plist>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
81
Sources/GrammaGem/System/UpdaterManager.swift
Normal file
81
Sources/GrammaGem/System/UpdaterManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)"
|
||||
|
||||
@ -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 <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
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
APP="$ROOT/dist/GrammaGem.app"
|
||||
VERSION="${1:?Usage: release.sh <version> 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 <version> 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-<ver>.dmg
|
||||
(Sparkle's sign_update tool, using your EdDSA private key)
|
||||
2. Add an <item> 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...)."
|
||||
|
||||
35
scripts/sign-app.sh
Executable file
35
scripts/sign-app.sh
Executable 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"
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user