diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a4bc3c9..c82db00 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,3 +14,8 @@ jobs: run: swift test - name: Build the app bundle run: ./Scripts/build-app.sh + # Whether Sparkle is embedded, signed and pointed at the right feed cannot be answered by + # swift test, because none of it exists until the app is assembled — and getting it wrong + # fails silently, months later, on the first update somebody accepts. + - name: Check the app can update itself + run: ./Scripts/test-update.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 06c5014..90c65b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ Notable changes, newest first. Dates are the day the work landed on `main`. The versions are what a release is tagged as; between tags, `main` is what is being used daily. +## Unreleased + +### Changed + +- **Loadout updates itself.** Instead of telling you a new version exists and sending you to a + download page, the app now fetches the new version and installs it — you are asked, it does the + work, and it reopens on the new version. No dragging anything into Applications again. + + An update is only accepted if it was signed with the key the running copy was built with, and it + is verified before it is unpacked, so a download that was tampered with in transit is refused + rather than installed. Nothing about your machine is sent when it checks. + + The check runs about once a day and Settings › Updates still holds the switch, the version you + are running, when it last checked, and a button to ask now. The switch you set in 0.3.2 is + carried over, so if you turned checks off they stay off. + ## 0.3.2 — 2026-08-28 ### Fixed diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..43ce0dc --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "f31cfb8fadb6bc974b9c4476ea08ba27eb7ab498ff3eb8c29ab3925c27d747fd", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "ac2def288cbff5cfc7df3ffef6abdf45b72bcb0a", + "version" : "2.9.6" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 99385f7..05677b2 100644 --- a/Package.swift +++ b/Package.swift @@ -4,9 +4,28 @@ import PackageDescription let package = Package( name: "Loadout", platforms: [.macOS(.v15)], + dependencies: [ + // Pinned because this framework installs executable code: it is what replaces Loadout.app + // on somebody's disk. Moving off this version is a deliberate review, not something a + // release build picks up on its own. + .package(url: "https://github.com/sparkle-project/Sparkle", exact: "2.9.6"), + ], targets: [ .target(name: "LoadoutCore"), - .executableTarget(name: "LoadoutApp", dependencies: ["LoadoutCore"]), + .executableTarget( + name: "LoadoutApp", + dependencies: [ + "LoadoutCore", + .product(name: "Sparkle", package: "Sparkle"), + ], + // The executable sits in Contents/MacOS and the framework is embedded in the standard + // sibling Frameworks directory by Scripts/build-app.sh. Without this rpath the app + // builds and then refuses to launch out of the bundle, because dyld has nowhere to + // look for Sparkle. + linkerSettings: [.unsafeFlags([ + "-Xlinker", "-rpath", "-Xlinker", "@loader_path/../Frameworks", + ])] + ), .testTarget(name: "LoadoutCoreTests", dependencies: ["LoadoutCore"]), ] ) diff --git a/README.md b/README.md index ece178c..9d9165f 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,11 @@ with a product's promise of support. Releases are built by `./Scripts/release.sh`, which refuses to run from a dirty tree, an untagged commit or a branch other than `main`, and runs the tests before signing anything. +Loadout updates itself. A release publishes three files together — the disk image, an identical +copy named `Loadout--update.dmg`, and a signed `appcast.xml` that points installed copies +at it. The app only accepts an update signed with the key it was built with. `release.sh` will not +print the publish command unless that signed feed exists. + ## License [MIT](LICENSE). diff --git a/Scripts/appcast.sh b/Scripts/appcast.sh new file mode 100755 index 0000000..83563cd --- /dev/null +++ b/Scripts/appcast.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# Signs one finished disk image and writes the Sparkle feed that is published beside it. +# +# The private EdDSA key stays in this machine's login keychain under the `loadout` account and is +# never written to a file here; only its public half is in the app's Info.plist, put there by +# Scripts/build-app.sh. Nothing in this script prints or exports the private key. +# +# The feed points at a second, identically-built copy of the image, published under the `-update` +# name. Same bytes, same signature, different GitHub asset — which is the only way to tell an +# update apart from a first install, because GitHub counts downloads per asset and nothing else. +# Both must be published or installed copies follow the feed to a 404. +# +# ./Scripts/appcast.sh signs dist/Loadout-.dmg +# ./Scripts/appcast.sh path/to/Some.dmg signs that one instead + +set -euo pipefail +cd "$(dirname "$0")/.." +ROOT="$PWD" + +# The same question build-app.sh asks, so the feed can never describe a version the app does not +# claim: only version tags count. +VERSION="$(git -C "$ROOT" describe --tags --match 'v[0-9]*' --always 2>/dev/null || echo "0.0.0")" +VERSION="${VERSION#v}" + +DMG="${1:-$ROOT/dist/Loadout-$VERSION.dmg}" +APP="$ROOT/dist/Loadout.app" +TOOLS="$ROOT/.build/artifacts/sparkle/Sparkle/bin" +GENERATE="$TOOLS/generate_appcast" +KEYS="$TOOLS/generate_keys" +OUTPUT="$ROOT/dist/appcast.xml" +UPDATE_DMG="$ROOT/dist/Loadout-$VERSION-update.dmg" + +[ -f "$DMG" ] || { echo "error: no disk image at $DMG" >&2; exit 1; } +[ -x "$GENERATE" ] || { + echo "error: Sparkle's release tools are missing — run swift package resolve" >&2 + exit 1 +} + +# The public key in the built app and the private key in the keychain are two halves of one thing. +# If they have drifted apart — a rebuilt key, a different machine — every installed copy would +# reject this update as forged, and it would look like the updater is broken rather than the key. +[ -f "$APP/Contents/Info.plist" ] || { + echo "error: no built app at $APP — run Scripts/build-app.sh first" >&2 + exit 1 +} +EXPECTED="$(/usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' "$APP/Contents/Info.plist")" +ACTUAL="$("$KEYS" --account loadout -p)" +[ "$ACTUAL" = "$EXPECTED" ] || { + echo "error: the Sparkle key in the keychain does not match the app" >&2 + exit 1 +} + +# generate_appcast reads a directory and describes everything in it, so it gets a directory holding +# exactly one image — the update copy, under the name the feed should point at. +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT +ditto "$DMG" "$STAGE/$(basename "$UPDATE_DMG")" + +"$GENERATE" \ + --account loadout \ + --download-url-prefix "https://github.com/migsilva89/loadout/releases/download/v$VERSION/" \ + --full-release-notes-url "https://github.com/migsilva89/loadout/blob/main/CHANGELOG.md" \ + --link "https://loadout.migsilva.dev" \ + --maximum-versions 1 \ + --maximum-deltas 0 \ + -o "$STAGE/appcast.xml" \ + "$STAGE" + +cp "$STAGE/appcast.xml" "$OUTPUT" +cp "$DMG" "$UPDATE_DMG" + +# An unsigned feed is worse than no feed: SURequireSignedFeed means installed copies would silently +# reject it, so the app would look like it had simply stopped finding updates. +xmllint --noout "$OUTPUT" +grep -q 'sparkle:edSignature=' "$OUTPUT" \ + || { echo "error: the update in appcast.xml is not signed" >&2; exit 1; } +grep -q ' + SUFeedURLhttps://github.com/migsilva89/loadout/releases/latest/download/appcast.xml + SUPublicEDKeylUaE3YVkBVqKzXHSQ5Kuex3WtTnffdZtNfHTFbA85ts= + SURequireSignedFeed + SUVerifyUpdateBeforeExtraction + SUEnableAutomaticChecks + SUAllowsAutomaticUpdates + SUScheduledCheckInterval86400 + SUSendProfileInfo PLIST plutil -lint "$APP/Contents/Info.plist" > /dev/null +# Inside out, and never with --deep. --deep is deprecated and applies the outer bundle's options +# to nested code, which is exactly wrong here: Autoupdate and Updater.app are separate programs +# that must each carry their own signature and the hardened runtime. Signing the app first and the +# framework after would also be pointless — changing anything inside a bundle invalidates the +# signature wrapped around it. +# +# This is the part that fails quietly. A wrongly signed Sparkle still notarises and still ships; +# it only breaks when somebody accepts an update, and then the installer cannot launch and the app +# just never updates. Scripts/test-update.sh checks the assembled bundle for that. if [[ -n "$SIGN_IDENTITY" ]]; then echo "→ Signing with Developer ID" - # No --deep: it is deprecated and signs nested code with the wrong options. There is nothing - # nested here anyway — one binary in one bundle. - codesign --force --sign "$SIGN_IDENTITY" --options runtime --timestamp "$APP" - codesign --verify --strict --verbose=1 "$APP" 2>&1 | tail -1 + SIGN=("$SIGN_IDENTITY" --options runtime --timestamp) else echo "→ Signing (ad hoc — only runs on this machine)" - codesign --force --sign - "$APP" 2>/dev/null + # No hardened runtime and no timestamp: an ad hoc signature cannot carry either, and asking for + # them makes codesign refuse rather than warn. + SIGN=(- --timestamp=none) fi +codesign --force --sign "${SIGN[@]}" "$SPARKLE/Versions/B/Autoupdate" 2>/dev/null +codesign --force --sign "${SIGN[@]}" "$SPARKLE/Versions/B/Updater.app" 2>/dev/null +codesign --force --sign "${SIGN[@]}" "$SPARKLE" 2>/dev/null +codesign --force --sign "${SIGN[@]}" "$APP" 2>/dev/null +codesign --verify --deep --strict --verbose=1 "$APP" 2>&1 | tail -1 + # Make sure Finder and the Dock pick the new icon up rather than a cached one. touch "$APP" diff --git a/Scripts/release.sh b/Scripts/release.sh index 8041735..0238ac0 100755 --- a/Scripts/release.sh +++ b/Scripts/release.sh @@ -17,6 +17,11 @@ # account password, and it never appears in a file here. # # Pass --force to skip the checks. Do that knowing what you are skipping. +# +# A full run writes three files that are published together: the disk image, an identical copy +# named Loadout--update.dmg, and the signed appcast.xml that points installed copies at +# it. Loadout updates itself from that feed, so leaving any of the three out breaks the updater +# for everybody who already has the app. set -euo pipefail cd "$(dirname "$0")/.." @@ -62,6 +67,8 @@ fi VERSION="$(git describe --tags --match 'v[0-9]*' --always)" VERSION="${VERSION#v}" DMG="$ROOT/dist/Loadout-$VERSION.dmg" +APPCAST="$ROOT/dist/appcast.xml" +UPDATE_DMG="$ROOT/dist/Loadout-$VERSION-update.dmg" # ------------------------------------------------------------------------- build @@ -72,13 +79,24 @@ if [ "${1:-}" != "--force" ]; then step "self-check" # Against the binary that was just built, rather than whatever sits in .build from yesterday. "$APP/Contents/MacOS/Loadout" --self-check >/dev/null || die "the self-check failed" - echo "self-check ok" + # And against the assembled bundle: whether Sparkle is embedded, signed and pointed at the + # right feed cannot be answered by swift test, because none of it exists until the app is put + # together. A wrongly signed Sparkle notarises happily and only fails months later, on the + # first update somebody accepts. + LOADOUT_APP="$APP" ./Scripts/test-update.sh >/dev/null 2>&1 \ + || die "the update bundle checks failed — run ./Scripts/test-update.sh to see which" + echo "self-check ok, update bundle ok" fi # --------------------------------------------------------------------------- dmg step "disk image" rm -rf "$STAGE" "$DMG" +# Last release's feed and update copy, gone before this one is built. Left in place, a build that +# stops short of notarising would leave dist/ looking like a complete release describing the +# previous version — and publishing that points every installed copy at the wrong download. +rm -f "$APPCAST" +rm -f "$ROOT"/dist/Loadout-*-update.dmg mkdir -p "$STAGE" cp -R "$APP" "$STAGE/" # The symlink is the whole installer: drag the app onto it and it is installed. @@ -113,7 +131,40 @@ else xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait xcrun stapler staple "$DMG" xcrun stapler validate "$DMG" + + # After stapling, never before: the feed signs the bytes of the finished image, and stapling + # changes them. A feed made from the unstapled image describes a download nobody will get. + step "update feed" + ./Scripts/appcast.sh "$DMG" fi step "done" printf '\033[1;32m✓ %s (%s)\033[0m\n' "$DMG" "$(du -h "$DMG" | cut -f1)" + +# Publishing is the last manual step, and it is the one that can quietly break every installed +# copy. Three files go up together or none do: the image people download by hand, the identical +# `-update` copy the feed points at, and the signed feed itself. Publish the image alone and the +# updater 404s; publish an unsigned feed and every app refuses it, because SURequireSignedFeed +# means an unverified feed is treated as no feed at all. +if [ -f "$APPCAST" ] \ + && grep -q 'sparkle:edSignature=' "$APPCAST" \ + && grep -q '