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
5 changes: 5 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 20 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
]
)
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>-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).
83 changes: 83 additions & 0 deletions Scripts/appcast.sh
Original file line number Diff line number Diff line change
@@ -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-<version>.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 '<!-- sparkle-signatures:' "$OUTPUT" \
|| { echo "error: appcast.xml itself is not signed" >&2; exit 1; }
grep -q "$(basename "$UPDATE_DMG")" "$OUTPUT" \
|| { echo "error: the feed does not point at the update copy" >&2; exit 1; }

echo "update feed → $OUTPUT"
echo "update image → $UPDATE_DMG"
56 changes: 50 additions & 6 deletions Scripts/build-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,21 @@ BINARY="$(swift build -c "$CONFIG" --product LoadoutApp --show-bin-path)/Loadout

echo "→ Assembling the bundle"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$APP/Contents/Frameworks"
cp "$BINARY" "$APP/Contents/MacOS/Loadout"

# Sparkle is what installs the next version, so it travels inside the app. `ditto` rather than
# `cp -R` because a framework is a bundle of symlinks and cp flattens them, which produces a
# framework that looks right and will not load.
SPARKLE="$APP/Contents/Frameworks/Sparkle.framework"
BUILT_SPARKLE="$(dirname "$BINARY")/Sparkle.framework"
[[ -d "$BUILT_SPARKLE" ]] || { echo "no Sparkle.framework beside $BINARY — run 'swift package resolve'"; exit 1; }
rm -rf "$SPARKLE"
ditto "$BUILT_SPARKLE" "$SPARKLE"
# Loadout is not sandboxed. Sparkle's XPC services exist only to carry the installer across a
# sandbox boundary, so keeping them ships two executables and two signatures that can never run.
rm -rf "$SPARKLE/Versions/B/XPCServices" "$SPARKLE/XPCServices"

if [[ -f "$ROOT/Resources/Loadout.icns" ]]; then
cp "$ROOT/Resources/Loadout.icns" "$APP/Contents/Resources/Loadout.icns"
else
Expand All @@ -57,23 +69,55 @@ cat > "$APP/Contents/Info.plist" <<PLIST
<key>LSApplicationCategoryType</key><string>public.app-category.developer-tools</string>
<key>NSHighResolutionCapable</key><true/>
<key>NSHumanReadableCopyright</key><string>Miguel Silva</string>

<!-- Sparkle. Loadout updates itself: it downloads the new version and replaces this copy.
The feed lives on the "latest" release, which is one address that never changes as
versions come and go. SUPublicEDKey is the public half of the key the release is signed
with — its private half is in this machine's login keychain under the account "loadout"
and is not in this repository. An update that is not signed with it is refused, which is
what stops a man in the middle handing the app a different Loadout.

SUAllowsAutomaticUpdates is false on purpose: Loadout asks before it replaces itself.
Installing silently under somebody working in the app is not a decision to take for them. -->
<key>SUFeedURL</key><string>https://github.com/migsilva89/loadout/releases/latest/download/appcast.xml</string>
<key>SUPublicEDKey</key><string>lUaE3YVkBVqKzXHSQ5Kuex3WtTnffdZtNfHTFbA85ts=</string>
<key>SURequireSignedFeed</key><true/>
<key>SUVerifyUpdateBeforeExtraction</key><true/>
<key>SUEnableAutomaticChecks</key><true/>
<key>SUAllowsAutomaticUpdates</key><false/>
<key>SUScheduledCheckInterval</key><integer>86400</integer>
<key>SUSendProfileInfo</key><false/>
</dict>
</plist>
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"

Expand Down
53 changes: 52 additions & 1 deletion Scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>-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")/.."
Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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 '<!-- sparkle-signatures:' "$APPCAST"; then
cat <<-EOF

then, to publish:
gh release create v$VERSION "$DMG" "$UPDATE_DMG" "$APPCAST" \
--title "Loadout $VERSION" --notes "…"

$(basename "$UPDATE_DMG") is the same image under a second name: the feed points installed
copies at it, so GitHub's per-asset counter separates updates from first installs. Publish
all three or the updater breaks.
EOF
else
cat <<-EOF

warning: there is no signed update feed in dist/, so this build must not be published as a
release — installed copies would keep following the previous version's feed, or reject an
unsigned one and stop finding updates at all. Only a signed and notarised run writes
$APPCAST. This build is for local testing.
EOF
fi
53 changes: 53 additions & 0 deletions Scripts/test-update.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/bin/bash
# The assembled app really can update itself: a loadable, correctly signed Sparkle inside the
# bundle, and the immutable half of the key that verifies every release.
#
# This suite exists because none of it fails loudly. A Sparkle signed with the wrong options, or a
# framework the app cannot find, still builds, still notarises and still installs — and then the
# first update somebody accepts goes nowhere, with no error anybody sees.
#
# ./Scripts/test-update.sh checks dist/Loadout.app
# LOADOUT_APP=/path/to/Loadout.app ./Scripts/test-update.sh

set -uo pipefail
cd "$(dirname "$0")/.."

APP="${LOADOUT_APP:-dist/Loadout.app}"
INFO="$APP/Contents/Info.plist"
FRAMEWORK="$APP/Contents/Frameworks/Sparkle.framework"
failures=0

check() {
local name="$1"
shift
if "$@" >/dev/null 2>&1; then echo "OK $name"; else failures=$((failures + 1)); echo "FAIL $name"; fi
}

check "there is an app to check" test -d "$APP"
check "Sparkle is embedded" test -f "$FRAMEWORK/Versions/B/Sparkle"
check "the installer is embedded" test -f "$FRAMEWORK/Versions/B/Autoupdate"
check "the update window is embedded" test -d "$FRAMEWORK/Versions/B/Updater.app"
check "unused sandbox services are absent" test ! -e "$FRAMEWORK/XPCServices"
check "the framework and its helpers are signed" codesign --verify --deep --strict "$FRAMEWORK"
check "the app and everything in it are signed" codesign --verify --deep --strict "$APP"
check "the app can find the embedded framework" sh -c \
"otool -l '$APP/Contents/MacOS/Loadout' | grep -q '@loader_path/../Frameworks'"
check "the feed has one stable address" sh -c \
"test \"\$(/usr/libexec/PlistBuddy -c 'Print :SUFeedURL' '$INFO')\" = \
'https://github.com/migsilva89/loadout/releases/latest/download/appcast.xml'"
check "updates require the public signing key" sh -c \
"test -n \"\$(/usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' '$INFO')\""
check "the download is verified before it is unpacked" sh -c \
"test \"\$(/usr/libexec/PlistBuddy -c 'Print :SUVerifyUpdateBeforeExtraction' '$INFO')\" = true"
check "the feed itself must also be signed" sh -c \
"test \"\$(/usr/libexec/PlistBuddy -c 'Print :SURequireSignedFeed' '$INFO')\" = true"
check "the automatic check is on" sh -c \
"test \"\$(/usr/libexec/PlistBuddy -c 'Print :SUEnableAutomaticChecks' '$INFO')\" = true"
check "it asks before replacing the app" sh -c \
"test \"\$(/usr/libexec/PlistBuddy -c 'Print :SUAllowsAutomaticUpdates' '$INFO')\" = false"
check "no system profile is sent" sh -c \
"test \"\$(/usr/libexec/PlistBuddy -c 'Print :SUSendProfileInfo' '$INFO')\" = false"

echo
if [ "$failures" -eq 0 ]; then echo "all good"; else echo "$failures failing"; fi
[ "$failures" -eq 0 ]
Loading