Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ build/
.DS_Store
*.p12
*.p8
# Vendored Sparkle framework + tools, fetched by `make sparkle` (pinned + checksummed).
third_party/
97 changes: 90 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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 \
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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))" || { \
Expand All @@ -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"
Expand All @@ -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

Expand Down
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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 <p8> --key-id <id> --issuer <uuid>`.

### 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
Expand Down
26 changes: 26 additions & 0 deletions Sources/trapps/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
50 changes: 50 additions & 0 deletions Sources/trapps/Updater.swift
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions Support/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,17 @@
<string>NSApplication</string>
<key>NSHighResolutionCapable</key>
<true/>
<!-- Sparkle auto-update. Feed is a small appcast.xml parked on a pinned
GitHub release (stable URL, never re-tagged); enclosures point at each
version's own release dmg. SUPublicEDKey is the EdDSA public key whose
private half lives in the release machine's Keychain (make sparkle-keys). -->
<key>SUFeedURL</key>
<string>https://github.com/GregTheGreek/trapps/releases/download/appcast/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>rcaXLvn0CTQcpN4gWNQgi5zaa7OYkjXwTp+iUeES314=</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUScheduledCheckInterval</key>
<integer>86400</integer>
</dict>
</plist>
4 changes: 4 additions & 0 deletions packaging/trapps.cask.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Loading