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"