From 276fa3cb416a437100f47a02e534ad3dc74380d4 Mon Sep 17 00:00:00 2001 From: "Gregory Markou (ai)" Date: Fri, 10 Jul 2026 18:19:06 +0200 Subject: [PATCH] feat: in-app auto-updates via Sparkle Add a "Check for Updates..." item and an "Automatically check for updates" toggle to the menu, backed by Sparkle. Updates are EdDSA-signed and pulled from GitHub Releases. Sparkle is vendored on demand: `make sparkle` fetches a pinned, checksum- verified Sparkle.framework into gitignored third_party/. The framework is embedded in the bundle and its nested XPC services / Updater.app / Autoupdate are re-signed inside-out with the Developer ID (hardened runtime + timestamp) before the app is sealed. `import Sparkle` is guarded by canImport, so the SwiftPM/test build compiles a no-op stub and stays free of the binary framework. The appcast (appcast.xml) lives on a pinned `appcast` GitHub release (stable URL); enclosures point at each version's own release. `make appcast` refreshes and signs it; `make sparkle-keys` handles one-time key setup. The cask gets `auto_updates true` so brew defers to the in-app updater. Co-Authored-By: Claude --- .gitignore | 2 + Makefile | 97 +++++++++++++++++++++-- README.md | 20 ++++- Sources/trapps/StatusItemController.swift | 26 ++++++ Sources/trapps/Updater.swift | 50 ++++++++++++ Support/Info.plist | 12 +++ packaging/trapps.cask.tmpl | 4 + 7 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 Sources/trapps/Updater.swift diff --git a/.gitignore b/.gitignore index c13da35..afff8fe 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ build/ .DS_Store *.p12 *.p8 +# Vendored Sparkle framework + tools, fetched by `make sparkle` (pinned + checksummed). +third_party/ diff --git a/Makefile b/Makefile index f0e7f30..147eff8 100644 --- a/Makefile +++ b/Makefile @@ -7,10 +7,32 @@ CASK_TMPL = packaging/trapps.cask.tmpl CASK = build/trapps.rb MACOS_TARGET = apple-macos13.0 +# --- Sparkle auto-update --- +# Pinned + checksummed so the vendored binary can't drift. `make sparkle` (a +# prerequisite of every compile) fetches it into gitignored third_party/. +SPARKLE_VERSION = 2.9.4 +SPARKLE_SHA256 = ce89daf967db1e1893ed3ebd67575ed82d3902563e3191ca92aaec9164fbdef9 +SPARKLE_DIR = third_party/Sparkle +SPARKLE_FW = $(SPARKLE_DIR)/Sparkle.framework +SPARKLE_BIN = $(SPARKLE_DIR)/bin +SPARKLE_URL = https://github.com/sparkle-project/Sparkle/releases/download/$(SPARKLE_VERSION)/Sparkle-$(SPARKLE_VERSION).tar.xz + +# Appcast feed lives on a pinned, never-re-tagged GitHub release; enclosures +# point at each version's own release. SUFeedURL in Info.plist must match APPCAST_URL. +APPCAST_TAG = appcast +APPCAST_URL = https://github.com/GregTheGreek/trapps/releases/download/$(APPCAST_TAG)/appcast.xml +RELEASE_URL_PREFIX = https://github.com/GregTheGreek/trapps/releases/download/v$(VERSION)/ + # -O for release; complete concurrency checking to match the SwiftPM build and # catch Sendable/isolation regressions before the Swift 6 language-mode switch. SWIFT_FLAGS = -O -strict-concurrency=complete +# Link Sparkle and bake the rpath so the embedded framework resolves at runtime +# (@rpath/Sparkle.framework -> Contents/Frameworks). `canImport(Sparkle)` in the +# Swift sources keys off this search path; the SwiftPM/test build omits it. +SPARKLE_FLAGS = -F $(SPARKLE_DIR) -framework Sparkle \ + -Xlinker -rpath -Xlinker @executable_path/../Frameworks + # Bundle resources. The .icns is generated from the 1024px master; the menu # bar glyph is the 54px art loaded as a template image and sized to 18pt at # runtime (crisp across 1x/2x/3x, so a single high-res file suffices). @@ -26,6 +48,26 @@ define STAGE_RESOURCES cp $(GLYPH) $(APP)/Contents/Resources/trapps-glyph.png endef +# Copy the vendored Sparkle.framework into the bundle before signing so the app's +# seal covers it. ditto preserves the framework's Versions/symlink layout. +define EMBED_SPARKLE + mkdir -p $(APP)/Contents/Frameworks + rm -rf $(APP)/Contents/Frameworks/Sparkle.framework + ditto $(SPARKLE_FW) $(APP)/Contents/Frameworks/Sparkle.framework +endef + +# Re-sign Sparkle's nested code inside-out with our identity (it ships ad-hoc +# signed). Must run after EMBED_SPARKLE and before the app is sealed. +# $(1) = signing identity, $(2) = extra flags (e.g. --options runtime --timestamp). +define SIGN_SPARKLE + fw="$(APP)/Contents/Frameworks/Sparkle.framework/Versions/B"; \ + codesign --force $(2) --sign "$(1)" "$$fw/XPCServices/Downloader.xpc"; \ + codesign --force $(2) --sign "$(1)" "$$fw/XPCServices/Installer.xpc"; \ + codesign --force $(2) --sign "$(1)" "$$fw/Autoupdate"; \ + codesign --force $(2) --sign "$(1)" "$$fw/Updater.app"; \ + codesign --force $(2) --sign "$(1)" "$(APP)/Contents/Frameworks/Sparkle.framework" +endef + # Prefer a real signing identity so the Accessibility grant survives rebuilds; # fall back to ad-hoc. Override with: make IDENTITY="Apple Development: ..." IDENTITY ?= $(shell security find-identity -v -p codesigning 2>/dev/null \ @@ -43,13 +85,26 @@ RELEASE_IDENTITY ?= $(shell security find-identity -v -p codesigning 2>/dev/null # CI overrides this with explicit --key/--key-id/--issuer flags. NOTARY_AUTH ?= --keychain-profile trapps-notary -.PHONY: build bundle run release notarize cask reset-ax clean +.PHONY: build bundle run release notarize cask sparkle sparkle-keys appcast reset-ax clean build: $(BINARY) -$(BINARY): $(SOURCES) +# Fetch, verify, and unpack the vendored Sparkle framework + tools on demand. +sparkle: $(SPARKLE_FW) +$(SPARKLE_FW): + @mkdir -p $(SPARKLE_DIR) build + @echo "Fetching Sparkle $(SPARKLE_VERSION)…" + @curl -fsSL -o build/sparkle.tar.xz "$(SPARKLE_URL)" + @echo "$(SPARKLE_SHA256) build/sparkle.tar.xz" | shasum -a 256 -c - \ + || { echo "error: Sparkle checksum mismatch"; rm -f build/sparkle.tar.xz; exit 1; } + @tar -xJf build/sparkle.tar.xz -C $(SPARKLE_DIR) ./Sparkle.framework ./bin + @chmod +x $(SPARKLE_BIN)/* 2>/dev/null || true + @rm -f build/sparkle.tar.xz + @echo "Sparkle $(SPARKLE_VERSION) vendored in $(SPARKLE_DIR)" + +$(BINARY): $(SOURCES) $(SPARKLE_FW) mkdir -p build - swiftc $(SWIFT_FLAGS) $(SOURCES) -o $(BINARY) + swiftc $(SWIFT_FLAGS) $(SPARKLE_FLAGS) $(SOURCES) -o $(BINARY) # App icon: build a full .iconset from the 1024px master and pack it into .icns. build/AppIcon.icns: $(ICON_SRC) @@ -73,6 +128,8 @@ bundle: build build/AppIcon.icns cp $(BINARY) $(APP)/Contents/MacOS/trapps cp Support/Info.plist $(APP)/Contents/Info.plist $(STAGE_RESOURCES) + $(EMBED_SPARKLE) + $(call SIGN_SPARKLE,$(IDENTITY),) codesign --force --sign "$(IDENTITY)" $(APP) run: bundle @@ -82,13 +139,13 @@ run: bundle # Universal binary, hardened runtime, Developer ID signature. `make release` # then `make notarize` produces a Gatekeeper-clean zip in build/. -build/trapps-arm64: $(SOURCES) +build/trapps-arm64: $(SOURCES) $(SPARKLE_FW) mkdir -p build - swiftc $(SWIFT_FLAGS) -target arm64-$(MACOS_TARGET) $(SOURCES) -o $@ + swiftc $(SWIFT_FLAGS) $(SPARKLE_FLAGS) -target arm64-$(MACOS_TARGET) $(SOURCES) -o $@ -build/trapps-x86_64: $(SOURCES) +build/trapps-x86_64: $(SOURCES) $(SPARKLE_FW) mkdir -p build - swiftc $(SWIFT_FLAGS) -target x86_64-$(MACOS_TARGET) $(SOURCES) -o $@ + swiftc $(SWIFT_FLAGS) $(SPARKLE_FLAGS) -target x86_64-$(MACOS_TARGET) $(SOURCES) -o $@ release: build/trapps-arm64 build/trapps-x86_64 build/AppIcon.icns @test -n "$(strip $(RELEASE_IDENTITY))" || { \ @@ -100,6 +157,8 @@ release: build/trapps-arm64 build/trapps-x86_64 build/AppIcon.icns lipo -create -output $(APP)/Contents/MacOS/trapps build/trapps-arm64 build/trapps-x86_64 cp Support/Info.plist $(APP)/Contents/Info.plist $(STAGE_RESOURCES) + $(EMBED_SPARKLE) + $(call SIGN_SPARKLE,$(RELEASE_IDENTITY),--options runtime --timestamp) codesign --force --options runtime --timestamp --sign "$(RELEASE_IDENTITY)" $(APP) ditto -c -k --keepParent $(APP) $(ZIP) @echo "Built $(ZIP); next: make notarize" @@ -119,6 +178,30 @@ cask: $(CASK_TMPL) sed -e 's/@@VERSION@@/$(VERSION)/' -e "s/@@SHA256@@/$$sha/" $(CASK_TMPL) > $(CASK); \ echo "Rendered $(CASK) (version $(VERSION), sha256 $$sha)" +# One-time: create (or show) the EdDSA signing key in your login Keychain and +# print the SUPublicEDKey to paste into Support/Info.plist. Idempotent - re-running +# reuses the existing key. The private half never leaves the Keychain. +sparkle-keys: $(SPARKLE_FW) + @$(SPARKLE_BIN)/generate_keys + +# Regenerate the appcast for the current version. Pulls the existing feed from the +# pinned '$(APPCAST_TAG)' release, appends this version (enclosure -> this version's +# release dmg/zip), signs everything with your Keychain EdDSA key, and writes +# build/appcast.xml. Run after `make release && make notarize`, then publish with: +# gh release upload $(APPCAST_TAG) build/appcast.xml --clobber +appcast: $(SPARKLE_FW) + @test -f $(ZIP) || { echo "error: $(ZIP) not found - run 'make release && make notarize' first."; exit 1; } + @rm -rf build/appcast && mkdir -p build/appcast + @cp $(ZIP) build/appcast/ + @curl -fsSL -o build/appcast/appcast.xml "$(APPCAST_URL)" \ + && echo "merged existing feed" || echo "no existing feed - starting fresh" + $(SPARKLE_BIN)/generate_appcast \ + --download-url-prefix "$(RELEASE_URL_PREFIX)" \ + --link "https://github.com/GregTheGreek/trapps" \ + build/appcast + @cp build/appcast/appcast.xml build/appcast.xml + @echo "Wrote build/appcast.xml; publish: gh release upload $(APPCAST_TAG) build/appcast.xml --clobber" + reset-ax: tccutil reset Accessibility com.gregthegreek.trapps diff --git a/README.md b/README.md index 4fdcb1b..d805097 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Trapps uses the macOS Accessibility API to enumerate each running app's menu bar - **Option-click** to open its right-click/context menu via the AXShowMenu accessibility action. Only some apps implement it; trapps beeps when one doesn't. - **No simulated input, by design**: trapps never synthesizes mouse or keyboard events and never moves your cursor. Everything goes through Accessibility API calls (inter-process messages to the target app). To physically rearrange icons, hold Cmd and drag them in the menu bar yourself - that's built into macOS. - **Launch at Login** toggle in the menu registers via `SMAppService`. +- **Updates**: the menu has "Check for Updates…" and an "Automatically check for updates" toggle, powered by [Sparkle](https://sparkle-project.org). Updates are EdDSA-signed and downloaded from GitHub Releases. - The trapps icon pins itself to the right-most third-party slot on every launch. - Reordering visible icons needs no app at all: hold Cmd and drag them directly in the menu bar (built into macOS). @@ -51,16 +52,31 @@ The Makefile signs with your Apple Development / Developer ID certificate if one Versioning is automated with [release-please](https://github.com/googleapis/release-please) (`.github/workflows/release-please.yml`); signing and notarization are done locally so the Developer ID private key never leaves the machine. 1. Conventional commits merged to `main` accumulate into an auto-maintained release PR. Merging it bumps the version in `Support/Info.plist` (via the `x-release-please-version` markers) and `CHANGELOG.md`, tags `vX.Y.Z`, and publishes the GitHub release (without assets). -2. Locally, on `main` at that tag, build the signed + notarized zip and attach it: +2. Locally, on `main` at that tag, build the signed + notarized zip, attach it, then refresh the Sparkle appcast so existing installs see the update: ```sh git pull + V=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Support/Info.plist) make release && make notarize - gh release upload "v$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Support/Info.plist)" build/Trapps-*.zip + gh release upload "v$V" "build/Trapps-$V.zip" + + make appcast # writes build/appcast.xml (signed) + gh release upload appcast build/appcast.xml --clobber ``` Requires a Developer ID Application identity in the keychain and a one-time notarytool profile: `xcrun notarytool store-credentials trapps-notary --key --key-id --issuer `. +### Auto-updates (Sparkle) + +Trapps updates itself via [Sparkle](https://sparkle-project.org). The feed (`appcast.xml`) is parked on a pinned GitHub release tagged `appcast` (a stable URL that is never re-tagged); `SUFeedURL` in `Support/Info.plist` points at it, and each item's enclosure points at that version's own `vX.Y.Z` release. `make appcast` pulls the current feed, appends the new version, and re-signs it. + +One-time setup: + +- `make sparkle-keys` creates the EdDSA signing key in your login keychain and prints the `SUPublicEDKey`. Paste that value into `Support/Info.plist` (the private half never leaves the keychain). Back it up: losing it means clients on the old key can't verify future updates. +- Create the pinned feed release once: `gh release create appcast --title "Sparkle appcast" --notes "Update feed" --latest=false`. + +Sparkle is vendored on demand: `make sparkle` fetches a pinned, checksum-verified `Sparkle.framework` into gitignored `third_party/` (any compile target pulls it in automatically). + Not distributable via the Mac App Store: driving other apps' menu bar items through the Accessibility API is incompatible with the App Store sandbox. ## Known limitations diff --git a/Sources/trapps/StatusItemController.swift b/Sources/trapps/StatusItemController.swift index 19eb8ec..4dd94fe 100644 --- a/Sources/trapps/StatusItemController.swift +++ b/Sources/trapps/StatusItemController.swift @@ -6,6 +6,7 @@ import ServiceManagement final class StatusItemController: NSObject, NSMenuDelegate { private let statusItem: NSStatusItem private let scanner = MenuBarScanner() + private let updater = Updater() private var isMenuOpen = false private var hotKey: HotKey? // Written only during init (main actor) and read only in deinit, which runs @@ -107,6 +108,23 @@ final class StatusItemController: NSObject, NSMenuDelegate { } menu.addItem(.separator()) + + // Update controls are only shown when Sparkle is actually linked (the + // shipped Makefile build); the SwiftPM/bare-binary build has no updater. + if updater.isAvailable { + let check = NSMenuItem(title: "Check for Updates…", action: #selector(checkForUpdates), keyEquivalent: "") + check.target = self + check.isEnabled = updater.canCheckForUpdates + menu.addItem(check) + + let auto = NSMenuItem(title: "Automatically check for updates", action: #selector(toggleAutoUpdate), keyEquivalent: "") + auto.target = self + auto.state = updater.automaticallyChecksForUpdates ? .on : .off + menu.addItem(auto) + + menu.addItem(.separator()) + } + let launch = NSMenuItem(title: "Launch at Login", action: #selector(toggleLaunchAtLogin), keyEquivalent: "") launch.target = self launch.state = SMAppService.mainApp.status == .enabled ? .on : .off @@ -205,6 +223,14 @@ final class StatusItemController: NSObject, NSMenuDelegate { } } + @objc private func checkForUpdates() { + updater.checkForUpdates() + } + + @objc private func toggleAutoUpdate() { + updater.automaticallyChecksForUpdates.toggle() + } + @objc private func toggleLaunchAtLogin() { do { if SMAppService.mainApp.status == .enabled { diff --git a/Sources/trapps/Updater.swift b/Sources/trapps/Updater.swift new file mode 100644 index 0000000..322ad16 --- /dev/null +++ b/Sources/trapps/Updater.swift @@ -0,0 +1,50 @@ +// In-app updates via Sparkle. Sparkle is linked only by the Makefile build +// (which passes `-F third_party/Sparkle -framework Sparkle`); the SwiftPM/test +// build has no such search path, so `canImport(Sparkle)` is false there and the +// stub below compiles instead. This keeps `swift test` free of the vendored +// binary framework while the shipped app gets the real updater. +// +// Feed URL and public EdDSA key are read from Info.plist (SUFeedURL / +// SUPublicEDKey); see the Makefile's `appcast` target and packaging/README.md. + +#if canImport(Sparkle) +import Sparkle + +@MainActor +final class Updater { + // startingUpdater: true kicks off the scheduled background checker at launch. + // No delegates: the standard user driver handles the whole check/download/ + // install/relaunch UI on its own. + private let controller = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: nil, + userDriverDelegate: nil + ) + + /// Whether Sparkle is actually linked into this build (true here). + let isAvailable = true + + /// False while a check is already in flight, so the menu item can disable itself. + var canCheckForUpdates: Bool { controller.updater.canCheckForUpdates } + + var automaticallyChecksForUpdates: Bool { + get { controller.updater.automaticallyChecksForUpdates } + set { controller.updater.automaticallyChecksForUpdates = newValue } + } + + func checkForUpdates() { controller.checkForUpdates(nil) } +} + +#else + +/// No-op stand-in for builds without Sparkle linked (SwiftPM/tests, bare binary). +/// `isAvailable` is false so the update menu items hide themselves. +@MainActor +final class Updater { + let isAvailable = false + let canCheckForUpdates = false + var automaticallyChecksForUpdates = false + func checkForUpdates() {} +} + +#endif diff --git a/Support/Info.plist b/Support/Info.plist index ee0cd0b..33a240c 100644 --- a/Support/Info.plist +++ b/Support/Info.plist @@ -28,5 +28,17 @@ NSApplication NSHighResolutionCapable + + SUFeedURL + https://github.com/GregTheGreek/trapps/releases/download/appcast/appcast.xml + SUPublicEDKey + rcaXLvn0CTQcpN4gWNQgi5zaa7OYkjXwTp+iUeES314= + SUEnableAutomaticChecks + + SUScheduledCheckInterval + 86400 diff --git a/packaging/trapps.cask.tmpl b/packaging/trapps.cask.tmpl index 650c3d3..733d48d 100644 --- a/packaging/trapps.cask.tmpl +++ b/packaging/trapps.cask.tmpl @@ -9,6 +9,10 @@ cask "trapps" do depends_on macos: :ventura # macOS 13+ (bare symbol implies >=) + # Trapps updates itself via Sparkle; tell brew so `brew upgrade` defers to it + # rather than fighting the in-app updater. + auto_updates true + app "Trapps.app" zap trash: [