From 537244615a4e225df5e22665f86bd4403ac7295f Mon Sep 17 00:00:00 2001 From: Karim Masmoudi <125830216+Kernel-Hunter@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:09:27 +0100 Subject: [PATCH 1/4] Update Boost from inside the app with Sparkle Updating meant noticing a release on GitHub, deleting the old app and installing the new one by hand, so most people stayed on whatever they first installed. Boost now uses Sparkle 2.10.0 (pinned exactly) to find a new version, show it, and install it when the user clicks Install. Nothing is checked until the user agrees: Sparkle asks on the second launch, and Check for Updates in the Boost menu and the toggle in Settings work at any time. Automatic installs are off and no system profile is sent. build.sh embeds Sparkle.framework in Contents/Frameworks and signs inside out (Sparkle's helpers, the framework, then the app) instead of --deep, for both the local identity and the ad-hoc fallback CI uses. Sparkle accepts an update between two ad-hoc builds because it trusts the EdDSA signature on the zip and only requires the new app's own code signature to be valid. That was tested end to end with two ad-hoc builds. --- Package.resolved | 15 +++++ Package.swift | 23 +++++++- Scripts/build.sh | 50 ++++++++++++++-- Sources/BoostKit/SettingsView.swift | 37 ++++++++++++ Sources/BoostKit/Updates.swift | 91 +++++++++++++++++++++++++++++ Sources/boost/BoostApp.swift | 5 ++ 6 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 Package.resolved create mode 100644 Sources/BoostKit/Updates.swift diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..8427284 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "7b2c8ed54b85934fcab2d698d90e15c784bed975ba6bda797765b8526d57e7c6", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "eef1a539a373c1f1a320624b1130fc5de7b2e100", + "version" : "2.10.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index d72d562..ed09dc3 100644 --- a/Package.swift +++ b/Package.swift @@ -8,9 +8,28 @@ import PackageDescription let package = Package( name: "Boost", platforms: [.macOS(.v14)], + dependencies: [ + // In-app updates. Pinned to an exact version rather than a range: this + // code replaces the app on people's machines, so a new Sparkle should + // arrive as a deliberate bump someone read the changelog for, not as a + // side effect of resolving packages. + .package(url: "https://github.com/sparkle-project/Sparkle", exact: "2.10.0"), + ], targets: [ - .target(name: "BoostKit"), - .executableTarget(name: "boost", dependencies: ["BoostKit"]), + .target(name: "BoostKit", dependencies: [ + .product(name: "Sparkle", package: "Sparkle"), + ]), + .executableTarget( + name: "boost", + dependencies: ["BoostKit"], + // Sparkle ships as a framework, not a static library, so the binary + // has to be able to find it once build.sh copies it into + // Contents/Frameworks. SwiftPM only adds rpaths for its own build + // folder, which does not exist on anyone else's Mac. + linkerSettings: [ + .unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../Frameworks"]), + ] + ), .testTarget(name: "BoostKitTests", dependencies: ["BoostKit"]), ] ) diff --git a/Scripts/build.sh b/Scripts/build.sh index 8e0a5a1..ad8b1d2 100755 --- a/Scripts/build.sh +++ b/Scripts/build.sh @@ -12,6 +12,13 @@ cd "$(dirname "$0")/.." # separately, so a tag and a bundle cannot disagree about what they are. VERSION="1.4.1" +# Where the app looks for new versions, and the key every update must be +# signed with. The feed is docs/appcast.xml, served by the project's GitHub +# Pages site; Scripts/appcast.sh adds each release to it. The public key's +# private half never leaves the maintainer's Keychain. See docs/releasing.md. +FEED_URL="https://kernel-hunter.github.io/boost/appcast.xml" +ED_PUBLIC_KEY="QKKfpvfADDZDhLbEDTOZ4QKgc5EgHgxP3hGulato8T8=" + DEST="/Applications/Boost.app" BUILD="build.noindex" STAGE="$BUILD/Boost.app" @@ -55,9 +62,18 @@ done echo " universal binary: $SLICES" rm -rf "$STAGE" -mkdir -p "$STAGE/Contents/MacOS" "$STAGE/Contents/Resources" +mkdir -p "$STAGE/Contents/MacOS" "$STAGE/Contents/Resources" "$STAGE/Contents/Frameworks" cp "$BUILD/Boost" "$STAGE/Contents/MacOS/Boost" +# Sparkle, for in-app updates. SwiftPM leaves the framework next to the +# binary; the binary looks for it in Contents/Frameworks (the rpath is set in +# Package.swift). ditto rather than cp -R, because a framework is mostly +# symlinks and cp can flatten them into copies, which breaks its signature. +if [ ! -d "$BIN_PATH/Sparkle.framework" ]; then + echo "error: Sparkle.framework not found in $BIN_PATH" >&2; exit 1 +fi +ditto "$BIN_PATH/Sparkle.framework" "$STAGE/Contents/Frameworks/Sparkle.framework" + cat > "$STAGE/Contents/Info.plist" < @@ -75,6 +91,9 @@ cat > "$STAGE/Contents/Info.plist" <NSHighResolutionCapable NSPrincipalClassNSApplication NSSupportsAutomaticTermination + SUFeedURL$FEED_URL + SUPublicEDKey$ED_PUBLIC_KEY + SUAllowsAutomaticUpdates PLIST @@ -85,14 +104,37 @@ PLIST # orphaned the grant and silently broke auto-quit-on-close. This identity # stays the same across rebuilds, so the grant survives them. # One-time setup that created it: see ../docs/codesigning.md +# +# CI has no such identity, so release builds are ad-hoc. Sparkle accepts an +# update between two ad-hoc builds: what it trusts is the EdDSA signature on +# the zip, and it only asks that the new app's own code signature be valid, +# not that it match the old one. See docs/releasing.md. if security find-identity -p codesigning -v 2>/dev/null | grep -q "Boost Local Dev"; then - codesign --force --deep --sign "Boost Local Dev" "$STAGE" 2>&1 | grep -v "^$" || true + IDENTITY="Boost Local Dev" else - echo " WARNING: 'Boost Local Dev' signing identity not found — falling back to ad-hoc." + echo " WARNING: 'Boost Local Dev' signing identity not found, falling back to ad-hoc." echo " Accessibility permission will need re-granting after this build." - codesign --force --sign - "$STAGE" 2>/dev/null || echo " (unsigned — still runs)" + IDENTITY="-" fi +# Inside out, never --deep. --deep signs nested code in whatever order it +# finds it and gives every piece the same options, and Apple has deprecated +# it for signing. Sparkle's helpers are signed first, then the framework that +# contains them, then the app that contains the framework: each signature +# covers the ones inside it, so signing in any other order leaves a seal over +# code that was changed afterwards. +SPARKLE="$STAGE/Contents/Frameworks/Sparkle.framework/Versions/B" +sign() { codesign --force --sign "$IDENTITY" "$@"; } +sign "$SPARKLE/XPCServices/Installer.xpc" +# The downloader service carries entitlements of its own (network client). +sign --preserve-metadata=entitlements "$SPARKLE/XPCServices/Downloader.xpc" +sign "$SPARKLE/Autoupdate" +sign "$SPARKLE/Updater.app" +sign "$STAGE/Contents/Frameworks/Sparkle.framework" +sign "$STAGE" +codesign --verify --deep --strict "$STAGE" +echo " signed: $IDENTITY" + if [ "$INSTALL" = 0 ]; then echo "built (not installed): $STAGE"; exit 0; fi pkill -x Boost 2>/dev/null || true diff --git a/Sources/BoostKit/SettingsView.swift b/Sources/BoostKit/SettingsView.swift index 23735f7..20c65ca 100644 --- a/Sources/BoostKit/SettingsView.swift +++ b/Sources/BoostKit/SettingsView.swift @@ -8,6 +8,7 @@ public struct BoostSettingsView: View { @ObservedObject private var rules = Rules.shared @ObservedObject private var autoPause = AutoPause.shared @StateObject private var login = LoginItemModel() + @ObservedObject private var updates = Updates.shared public init() {} @@ -30,6 +31,21 @@ public struct BoostSettingsView: View { .help("Nothing is ever left frozen because Boost went away.") } + if updates.isAvailable { + Section("Updates") { + Toggle("Check for updates automatically", isOn: $updates.automaticallyChecks) + .help("Once a day, Boost asks GitHub whether a newer version exists. " + + "Nothing is installed until you click Install.") + HStack { + Text(updateStatus) + .font(.caption).foregroundStyle(.secondary) + Spacer() + Button("Check Now") { updates.checkForUpdates() } + .disabled(!updates.canCheck) + } + } + } + Section("Sleeping apps") { Toggle("Pause apps I leave alone", isOn: $autoPause.enabled) .help("Freezes the apps listed below after they have been in the " @@ -115,4 +131,25 @@ public struct BoostSettingsView: View { .frame(width: 460) .fixedSize(horizontal: false, vertical: true) } + + private var updateStatus: String { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" + guard let last = updates.lastChecked else { return "Boost \(version)" } + return "Boost \(version), last checked \(last.formatted(.relative(presentation: .named)))" + } +} + +/// The menu command. Its own view so it can follow `canCheck`: a Commands +/// builder cannot observe an object by itself. +public struct CheckForUpdatesButton: View { + @ObservedObject private var updates = Updates.shared + + public init() {} + + public var body: some View { + if updates.isAvailable { + Button("Check for Updates…") { updates.checkForUpdates() } + .disabled(!updates.canCheck) + } + } } diff --git a/Sources/BoostKit/Updates.swift b/Sources/BoostKit/Updates.swift new file mode 100644 index 0000000..a0c8540 --- /dev/null +++ b/Sources/BoostKit/Updates.swift @@ -0,0 +1,91 @@ +import AppKit +import Combine +import Sparkle + +/// In-app updates, through Sparkle. +/// +/// Before this, updating meant noticing a release on GitHub, deleting the old +/// app and installing the new one by hand, and most people never did. Sparkle +/// reads a small feed (`SUFeedURL` in Info.plist), and when it lists a newer +/// version it shows the release and an Install button. +/// +/// What it does not do on its own, on purpose: +/// - It does not check anything until the person says yes. Sparkle asks on the +/// second launch, not the first, and nothing goes over the network before +/// that answer. Info.plist leaves `SUEnableAutomaticChecks` unset so that +/// prompt happens; setting it would decide for them. +/// - It never installs without a click. `SUAllowsAutomaticUpdates` is off in +/// Info.plist, which also removes the "install automatically" checkbox from +/// Sparkle's prompt. +/// - It sends no system profile. `SUEnableSystemProfiling` is never set. +/// +/// Every download is checked against the EdDSA key in Info.plist +/// (`SUPublicEDKey`) before anything is replaced, so a feed or zip someone +/// tampered with is refused. +@MainActor +public final class Updates: ObservableObject { + public static let shared = Updates() + + /// False while a check is already running, and always false in a build + /// that has no feed. The menu item and the button follow it. + @Published public private(set) var canCheck = false + + /// Sparkle stores this itself, in the app's defaults. Mirrored here so + /// SwiftUI can bind a toggle to it. + @Published public var automaticallyChecks = false { + didSet { + guard isRunning, automaticallyChecks != updater.automaticallyChecksForUpdates else { return } + updater.automaticallyChecksForUpdates = automaticallyChecks + } + } + + /// Whether this build can update itself at all. `swift run`, the snapshot + /// mode and the test runner have no Info.plist with a feed, and starting + /// Sparkle there only produces an error dialog about a missing SUFeedURL. + public let isAvailable: Bool + + private let controller: SPUStandardUpdaterController + private var isRunning = false + private var watchers: Set = [] + + private var updater: SPUUpdater { controller.updater } + + private init() { + controller = SPUStandardUpdaterController( + startingUpdater: false, updaterDelegate: nil, userDriverDelegate: nil) + isAvailable = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") != nil + } + + /// Called once at launch. Starting the updater is not the same as checking: + /// with automatic checks off, or not yet agreed to, it schedules nothing. + public func start() { + guard isAvailable, !isRunning else { return } + controller.startUpdater() + isRunning = true + + updater.publisher(for: \.canCheckForUpdates) + .receive(on: RunLoop.main) + .sink { [weak self] in self?.canCheck = $0 } + .store(in: &watchers) + updater.publisher(for: \.automaticallyChecksForUpdates) + .receive(on: RunLoop.main) + .sink { [weak self] value in + guard let self, self.automaticallyChecks != value else { return } + self.automaticallyChecks = value + } + .store(in: &watchers) + } + + /// The "Check for Updates…" action. Boost is often in the background when + /// this is clicked, because it was clicked from the menu bar popover or a + /// Settings window that does not own focus, and Sparkle's window would then + /// open behind whatever app is in front. So come forward first. + public func checkForUpdates() { + guard isRunning else { return } + NSApp.activate(ignoringOtherApps: true) + controller.checkForUpdates(nil) + } + + /// When the last check finished, for the Settings window. Nil if never. + public var lastChecked: Date? { isRunning ? updater.lastUpdateCheckDate : nil } +} diff --git a/Sources/boost/BoostApp.swift b/Sources/boost/BoostApp.swift index 7abb45f..8010aa4 100644 --- a/Sources/boost/BoostApp.swift +++ b/Sources/boost/BoostApp.swift @@ -9,6 +9,7 @@ struct BoostApp: App { if let directory = Snapshot.requestedDirectory() { MainActor.assumeIsolated { Snapshot.run(to: directory) } } + MainActor.assumeIsolated { Updates.shared.start() } } var body: some Scene { @@ -19,6 +20,10 @@ struct BoostApp: App { .defaultSize(width: 1040, height: 760) .commands { CommandGroup(replacing: .newItem) {} + // Under the app name, next to About, where Mac apps put it. + CommandGroup(after: .appInfo) { + CheckForUpdatesButton() + } CommandGroup(after: .toolbar) { Button("Refresh") { Engine.shared.refresh() } .keyboardShortcut("r", modifiers: .command) From 616067d6d72ca9de31e3961ce9b837f6b920e9d6 Mon Sep 17 00:00:00 2001 From: Karim Masmoudi <125830216+Kernel-Hunter@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:09:27 +0100 Subject: [PATCH 2/4] Add the update feed and a script that signs releases into it The app reads docs/appcast.xml from the project's GitHub Pages site. Scripts/appcast.sh adds a published release to it: it downloads the zip CI built, checks it against the SHA-256 in the release notes, checks the app inside has the right version and public key, signs it with the EdDSA key from the maintainer's Keychain, and commits the feed. Signing stays on the maintainer's Mac. As a GitHub secret the key would let anyone who got into the repo push an update to every install. docs/releasing.md has the steps. --- .github/workflows/release.yml | 7 +- CONTRIBUTING.md | 5 + Scripts/appcast.sh | 177 ++++++++++++++++++++++++++++++++++ docs/appcast.xml | 9 ++ docs/releasing.md | 83 ++++++++++++++++ 5 files changed, 279 insertions(+), 2 deletions(-) create mode 100755 Scripts/appcast.sh create mode 100644 docs/appcast.xml create mode 100644 docs/releasing.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e523926..1479ac7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,8 +55,11 @@ jobs: Or download \`Boost.zip\` below and drag it to Applications. - Boost is **not notarized** — it is signed with a local certificate, so - a direct download will be stopped by Gatekeeper the first time. + Already on Boost 1.5.0 or later? Choose Check for Updates in the Boost + menu and click Install. + + Boost is **not notarized**, so a direct download will be stopped by + Gatekeeper the first time. Homebrew handles this for you; if you downloaded the zip by hand, right-click the app and choose *Open*. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77c474c..1d20d63 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,6 +71,11 @@ Explain the problem, not the diff. A reader can see what changed; what they cannot see is what was wrong, why the obvious fix was wrong, and what convinced you it works now. +## Releasing + +Maintainers: see [docs/releasing.md](docs/releasing.md). Pushing a tag builds +the release; `Scripts/appcast.sh` then signs it and adds it to the update feed. + ## Before opening a PR ```bash diff --git a/Scripts/appcast.sh b/Scripts/appcast.sh new file mode 100755 index 0000000..ebf1df0 --- /dev/null +++ b/Scripts/appcast.sh @@ -0,0 +1,177 @@ +#!/bin/bash +# Adds a published release to the update feed, docs/appcast.xml. +# Usage: Scripts/appcast.sh e.g. Scripts/appcast.sh 1.5.0 +# +# Run it after the release workflow has published v. It downloads +# that release's Boost.zip, checks it against the SHA-256 the workflow wrote +# into the release notes, signs it with the EdDSA key in your login Keychain, +# and adds (or replaces) the matching item in the feed. Then it commits. +# Pushing that commit is what makes existing installs see the update. +# +# Signing happens here, on your Mac, and not in CI on purpose: the private key +# would otherwise have to be a GitHub secret, and anyone who got it could ship +# an update to every Boost user. +# +# Needs: curl, python3, and Sparkle's sign_update. `swift build` downloads it +# into .build/artifacts; set SPARKLE_BIN to use another copy. The first run +# makes macOS ask whether sign_update may use the key; choose Always Allow. +# If the Keychain is not an option, SPARKLE_KEY_FILE= +# signs with that instead (and skips the public key check below, which needs +# the Keychain). +set -euo pipefail +cd "$(dirname "$0")/.." + +VERSION="${1:-}" +if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "usage: Scripts/appcast.sh (for example 1.5.0)" >&2 + exit 1 +fi + +REPO="Kernel-Hunter/boost" +TAG="v$VERSION" +FEED="docs/appcast.xml" +ZIP_URL="https://github.com/$REPO/releases/download/$TAG/Boost.zip" +NOTES_URL="https://github.com/$REPO/releases/tag/$TAG" + +SPARKLE_BIN="${SPARKLE_BIN:-.build/artifacts/sparkle/Sparkle/bin}" +if [ ! -x "$SPARKLE_BIN/sign_update" ]; then + echo "sign_update not found in $SPARKLE_BIN. Run 'swift build' once, or set SPARKLE_BIN." >&2 + exit 1 +fi + +# The feed edit goes into a commit, so start from a clean feed. A half-edited +# appcast.xml mixed into this commit is how a broken feed reaches users. +if ! git diff --quiet -- "$FEED" || ! git diff --cached --quiet -- "$FEED"; then + echo "$FEED has uncommitted changes. Commit or discard them first." >&2 + exit 1 +fi + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +echo "reading release $TAG…" +curl -fsSL "https://api.github.com/repos/$REPO/releases/tags/$TAG" -o "$WORK/release.json" \ + || { echo "No published release $TAG on GitHub yet." >&2; exit 1; } + +# The release notes carry a line "SHA-256: ``", written by release.yml +# from the zip it built. Checking against it means the file signed here is the +# one CI built, not whatever a redirect or a cache happened to hand back. +read -r EXPECTED PUBLISHED < <(python3 -I - "$WORK/release.json" <<'PY' +import json, re, sys +release = json.load(open(sys.argv[1])) +match = re.search(r"SHA-256:\s*`([0-9a-f]{64})`", release.get("body") or "") +print(match.group(1) if match else "-", release.get("published_at") or "-") +PY +) +if [ "$EXPECTED" = "-" ]; then + echo "The notes for $TAG have no SHA-256 line. Was it published by release.yml?" >&2 + exit 1 +fi + +echo "downloading Boost.zip…" +curl -fsSL "$ZIP_URL" -o "$WORK/Boost.zip" +ACTUAL="$(shasum -a 256 "$WORK/Boost.zip" | cut -d' ' -f1)" +if [ "$ACTUAL" != "$EXPECTED" ]; then + echo "Checksum mismatch for $ZIP_URL" >&2 + echo " release notes: $EXPECTED" >&2 + echo " downloaded: $ACTUAL" >&2 + exit 1 +fi +echo " SHA-256 matches the release notes" + +# Two mistakes that would only show up on users' Macs, caught here instead: +# a zip whose app is a different version than its tag, and an app that +# carries a different public key than the one we are about to sign with +# (every install of it would then refuse the next update). +ditto -x -k "$WORK/Boost.zip" "$WORK/unzipped" +PLIST="$WORK/unzipped/Boost.app/Contents/Info.plist" +APP_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$PLIST")" +APP_KEY="$(/usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' "$PLIST" 2>/dev/null || true)" +if [ "$APP_VERSION" != "$VERSION" ]; then + echo "The app in the zip says $APP_VERSION, not $VERSION." >&2 + exit 1 +fi +if [ -n "${SPARKLE_KEY_FILE:-}" ]; then + KEY_ARGS=(--ed-key-file "$SPARKLE_KEY_FILE") + echo " signing with $SPARKLE_KEY_FILE; not checked against the app's key ($APP_KEY)" +else + KEY_ARGS=() + OUR_KEY="$("$SPARKLE_BIN/generate_keys" -p)" + if [ "$APP_KEY" != "$OUR_KEY" ]; then + echo "The app's SUPublicEDKey ($APP_KEY) is not the key in your Keychain ($OUR_KEY)." >&2 + exit 1 + fi +fi + +# Prints: sparkle:edSignature="…" length="…" +echo "signing…" +if ! SIGNED="$("$SPARKLE_BIN/sign_update" ${KEY_ARGS[@]+"${KEY_ARGS[@]}"} "$WORK/Boost.zip" 2>&1)"; then + echo "sign_update failed: $SIGNED" >&2 + exit 1 +fi +SIGNATURE="$(sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p' <<<"$SIGNED")" +LENGTH="$(sed -n 's/.*length="\([0-9]*\)".*/\1/p' <<<"$SIGNED")" +if [ -z "$SIGNATURE" ] || [ -z "$LENGTH" ]; then + echo "Could not read sign_update's output: $SIGNED" >&2 + exit 1 +fi + +python3 -I - "$FEED" "$VERSION" "$PUBLISHED" "$ZIP_URL" "$LENGTH" "$SIGNATURE" "$NOTES_URL" <<'PY' +import sys +import xml.etree.ElementTree as ET +from datetime import datetime, timezone +from email.utils import format_datetime + +feed, version, published, url, length, signature, notes = sys.argv[1:] +SPARKLE = "http://www.andymatuschak.org/xml-namespaces/sparkle" +ET.register_namespace("sparkle", SPARKLE) +s = lambda tag: f"{{{SPARKLE}}}{tag}" + +tree = ET.parse(feed) +channel = tree.getroot().find("channel") + +# Replacing rather than appending, so running this twice for one version +# (after a re-upload, say) leaves one item, not two that disagree. +for old in channel.findall("item"): + if old.findtext(s("version")) == version: + channel.remove(old) + +when = datetime.now(timezone.utc) if published == "-" else \ + datetime.strptime(published, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + +item = ET.Element("item") +ET.SubElement(item, "title").text = f"Boost {version}" +ET.SubElement(item, "pubDate").text = format_datetime(when) +ET.SubElement(item, s("version")).text = version +ET.SubElement(item, s("shortVersionString")).text = version +ET.SubElement(item, s("minimumSystemVersion")).text = "14.0" +# Shown inside the update window. Links in it open in the browser. +ET.SubElement(item, "description").text = ( + f'

See what changed in Boost {version} on GitHub.

') +# The "Version History" link Sparkle offers next to the notes. +ET.SubElement(item, s("fullReleaseNotesLink")).text = notes +ET.SubElement(item, "enclosure", { + "url": url, + "length": length, + "type": "application/octet-stream", + s("edSignature"): signature, +}) + +# Newest first, so the file reads like a changelog. +first = next((i for i, child in enumerate(channel) if child.tag == "item"), len(channel)) +channel.insert(first, item) + +ET.indent(tree, space=" ") +tree.write(feed, encoding="utf-8", xml_declaration=True) +with open(feed, "a") as f: + f.write("\n") +PY + +if git diff --quiet -- "$FEED"; then + echo "Boost $VERSION is already in $FEED exactly as signed now. Nothing to commit." + exit 0 +fi +git add "$FEED" +git commit -q -m "Add Boost $VERSION to the update feed" -- "$FEED" +echo "added Boost $VERSION to $FEED and committed it." +echo "Push it, and installed copies will offer the update on their next check." diff --git a/docs/appcast.xml b/docs/appcast.xml new file mode 100644 index 0000000..536ee31 --- /dev/null +++ b/docs/appcast.xml @@ -0,0 +1,9 @@ + + + + Boost + https://kernel-hunter.github.io/boost/appcast.xml + New versions of Boost. Edited by Scripts/appcast.sh, not by hand. + en + + diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..80d3d5d --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,83 @@ +# Releasing Boost + +A release has two halves. GitHub Actions builds and publishes the zip. Then +the maintainer signs that zip on their own Mac and adds it to the update +feed, which is what makes installed copies offer the update. + +## Steps + +1. Bump `VERSION` in `Scripts/build.sh`, commit, and push to `main`. +2. Tag and push the tag: + + ```bash + git tag v1.5.0 + git push origin v1.5.0 + ``` + + `.github/workflows/release.yml` checks that the tag matches `VERSION`, runs + the tests, builds `Boost.zip`, and publishes the release with the zip's + SHA-256 in the notes. Wait for it to finish. +3. Add the release to the update feed: + + ```bash + swift build # once, so Sparkle's tools are in .build/artifacts + ./Scripts/appcast.sh 1.5.0 + git push + ``` + + The script downloads the published zip, checks it against the SHA-256 in + the release notes, checks that the app inside is 1.5.0 and carries our + public key, signs the zip with the EdDSA key from your login Keychain, and + commits the new item in `docs/appcast.xml`. The first time, macOS asks + whether `sign_update` may use the key. Choose Always Allow. +4. GitHub Pages republishes `docs/` within a minute or two. Check that + lists the new version. +5. Update the Homebrew cask in `Kernel-Hunter/homebrew-boost` with the new + version and SHA-256, as before. + +Until step 3 is pushed, nobody is offered the update, so forgetting it only +means people update later. + +## How updates work + +Boost uses [Sparkle](https://sparkle-project.org) 2, pinned in +`Package.swift`. The app reads `SUFeedURL` (the appcast above) and +`SUPublicEDKey` from its Info.plist, both set in `Scripts/build.sh`. + +- **Nothing is checked until the user agrees.** Sparkle asks on the second + launch whether to check automatically. "Check for Updates…" in the Boost + menu and the toggle in Settings work at any time. +- **Nothing is installed without a click.** `SUAllowsAutomaticUpdates` is off. +- **Every update must be signed with our EdDSA key.** The app refuses a zip + whose signature does not match the public key it shipped with, before it + replaces anything. + +## The signing key + +The private key lives in the maintainer's login Keychain, where Sparkle's +`generate_keys` put it. It is deliberately not a GitHub secret: whoever holds +it can push an update to every Boost user, so it stays on one machine. Keep an +offline backup (`generate_keys -x `). If the Keychain is unavailable, +`SPARKLE_KEY_FILE= ./Scripts/appcast.sh ` signs with the +backup instead. + +If the key is ever lost, installed copies can no longer be updated through +Sparkle, and everyone would have to download a new build by hand once. + +## Why ad-hoc signed builds can update each other + +Release builds come from CI, which has no signing identity, so they are +ad-hoc signed and not notarized. An ad-hoc signature's designated requirement +is the build's own hash, so no two builds ever "match" in the code signing +sense. + +Sparkle 2 still accepts these updates, and this was tested end to end. In +`SUUpdateValidator.m` an app bundle update passes if either the EdDSA +signature checks out against the old app's `SUPublicEDKey`, or the new app's +code signature matches the old one. EdDSA passes here. Sparkle then also +requires the new app's own code signature to be valid, which is why +`build.sh` signs Sparkle's helpers, then the framework, then the app, inside +out. A broken signature anywhere in the bundle would make every update fail. + +Sparkle removes the quarantine flag from what it installs, so an update does +not trigger Gatekeeper the way a first download does. From b362215cf9957f96227ee57cc96b6babf6e7ffb0 Mon Sep 17 00:00:00 2001 From: Karim Masmoudi <125830216+Kernel-Hunter@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:09:27 +0100 Subject: [PATCH 3/4] Say what the update check sends instead of promising no network at all README, SECURITY.md and the website all said Boost makes no network requests and has no update check. With in-app updates that is no longer true, so say exactly what happens: one HTTPS request for the feed, no identifiers, only after the user agrees or clicks Check for Updates, and how to turn it off. Also corrects SECURITY.md, which said release builds are notarized. They are ad-hoc signed by CI and not notarized. --- README.md | 26 ++++++++--- SECURITY.md | 45 +++++++++++++++---- docs/guides/how-to-free-up-ram-on-mac.html | 2 +- .../how-to-uninstall-mac-apps-completely.html | 2 +- docs/guides/index.html | 2 +- docs/guides/is-cached-memory-bad-on-mac.html | 2 +- .../mac-system-data-storage-explained.html | 2 +- .../what-is-memory-pressure-on-mac.html | 2 +- docs/index.html | 12 ++--- 9 files changed, 70 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 1f07496..6591b1a 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ It lives in your menu bar, shows what is really using your RAM, pauses apps you If you looked at CleanMyMac, iStat Menus or AppCleaner and did not want a subscription, Boost covers the parts most people actually use. No account, no -analytics, no network access of any kind. +analytics, and the only network request it makes is an update check you can +turn off. ## Install @@ -46,13 +47,24 @@ cd boost && ./Scripts/build.sh Needs macOS 14 or later. One universal binary for Apple Silicon and Intel. It builds with the Command Line Tools, so Xcode is not required. +### Updating + +From 1.5.0 on, Boost updates itself. Choose Check for Updates… in the Boost +menu, or let it check once a day (it asks the second time you open it). When a +new version is out you see what changed and click Install. There is nothing to +delete or download by hand, and this works the same if you installed with +Homebrew. + +If you have 1.4.1 or older, update once the old way (`brew upgrade --cask boost`, +or download the new zip). After that the app does it. +
About signing and Gatekeeper
-Boost is signed with a local certificate, not notarized, because notarizing needs -a paid Apple Developer account. A downloaded `.zip` will be stopped by Gatekeeper +Boost is not notarized, because notarizing needs a paid Apple Developer +account. A downloaded `.zip` will be stopped by Gatekeeper the first time. The Homebrew cask clears the quarantine flag for you, and building from source never sets one, so use one of those two. @@ -165,8 +177,12 @@ far enough, the kernel may kill something on its own. | Login item | Starting Boost at login | Only if you turn it on | | Admin password | `/usr/sbin/purge` | Only if you turn on the optional disk cache purge | -No analytics, no telemetry, no update check, no network access of any kind. There -is nothing to opt out of. See [SECURITY.md](SECURITY.md). +No analytics and no telemetry. The one network request Boost makes is the update +check: it downloads a small file listing the latest version from +`kernel-hunter.github.io` over HTTPS. It sends no identifiers, only what any +download sends (your IP address, and a User-Agent naming Boost's version). It +happens only after you say yes to automatic checks, or when you click Check for +Updates. Turn it off in Settings, under Updates. See [SECURITY.md](SECURITY.md).
diff --git a/SECURITY.md b/SECURITY.md index 3dbe714..1a7c3cb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -25,9 +25,32 @@ Expect a reply within a week. There is no bounty — this is a solo project. | Accessibility (optional) | Count an app's open windows, for "close button quits the app" | Read-only, window counts only. Off unless you enable the feature | | Admin password (optional) | `/usr/sbin/purge` | Off by default. See below | -It does **not** read window contents, keystrokes, files, or the network. It has -no analytics, no telemetry, no update check, and makes no outbound connections -of any kind. There is nothing to opt out of. +It does **not** read window contents, keystrokes or files. It has no analytics +and no telemetry. Its only outbound connection is the update check below. + +## Update checks + +Boost updates itself with [Sparkle](https://sparkle-project.org) 2, pinned to +an exact version in `Package.swift`. + +- **What is sent.** One HTTPS GET for + `https://kernel-hunter.github.io/boost/appcast.xml`, a small file listing the + latest version. No identifiers, no system profile (`SUEnableSystemProfiling` + is never set). Like any request it carries your IP address and a User-Agent + with Boost's and Sparkle's version numbers. If you install an update, the zip + is downloaded from GitHub Releases. +- **When.** Never on the first launch. On the second launch Sparkle asks + whether to check automatically; say no and nothing is sent unless you click + Check for Updates. Say yes and it checks about once a day. +- **How to turn it off.** Settings, Updates, "Check for updates + automatically". +- **What gets installed.** Nothing without your click. Automatic installs are + disabled (`SUAllowsAutomaticUpdates` is off). Every update zip must carry an + EdDSA signature that matches the public key built into the app + (`SUPublicEDKey`), or Sparkle refuses it before replacing anything. The + private key never leaves the maintainer's Mac and is not stored in GitHub, so + a compromised GitHub account alone cannot ship an update. Details in + `docs/releasing.md`. ## The guard list @@ -59,17 +82,23 @@ Turn the feature off and Boost never calls the API. ## Code signing -Release builds are signed and notarized. Builds you make yourself are signed -with a local self-signed certificate (see `docs/codesigning.md`) — which is why -a build of your own will ask for Accessibility access again the first time. +Release builds are ad-hoc signed by CI and are not notarized, because +notarizing needs a paid Apple Developer account. Builds you make yourself are +signed with a local self-signed certificate if you created one (see +`docs/codesigning.md`), which is why a build of your own will ask for +Accessibility access again the first time. Updates are verified with Sparkle's +EdDSA signature, not with Apple code signing, as described above. Verify a release before trusting it: ```bash -codesign -dv --verbose=4 /Applications/Boost.app -spctl -a -vvv -t exec /Applications/Boost.app +shasum -a 256 Boost.zip # compare with the SHA-256 in the release notes +codesign --verify --deep --strict /Applications/Boost.app ``` +`spctl` will reject a release build, because it is not notarized. That is +expected and is not a sign of tampering. + ## Scope In scope: privilege escalation, signalling processes outside the documented diff --git a/docs/guides/how-to-free-up-ram-on-mac.html b/docs/guides/how-to-free-up-ram-on-mac.html index a904057..047f879 100644 --- a/docs/guides/how-to-free-up-ram-on-mac.html +++ b/docs/guides/how-to-free-up-ram-on-mac.html @@ -74,7 +74,7 @@ Guides diff --git a/docs/guides/how-to-uninstall-mac-apps-completely.html b/docs/guides/how-to-uninstall-mac-apps-completely.html index fe823c9..1db4f68 100644 --- a/docs/guides/how-to-uninstall-mac-apps-completely.html +++ b/docs/guides/how-to-uninstall-mac-apps-completely.html @@ -74,7 +74,7 @@ Guides diff --git a/docs/guides/index.html b/docs/guides/index.html index 1ce2dbe..4742de0 100644 --- a/docs/guides/index.html +++ b/docs/guides/index.html @@ -68,7 +68,7 @@ Guides diff --git a/docs/guides/is-cached-memory-bad-on-mac.html b/docs/guides/is-cached-memory-bad-on-mac.html index a2facbb..a8ea316 100644 --- a/docs/guides/is-cached-memory-bad-on-mac.html +++ b/docs/guides/is-cached-memory-bad-on-mac.html @@ -74,7 +74,7 @@ Guides diff --git a/docs/guides/mac-system-data-storage-explained.html b/docs/guides/mac-system-data-storage-explained.html index c6124f0..b25bc82 100644 --- a/docs/guides/mac-system-data-storage-explained.html +++ b/docs/guides/mac-system-data-storage-explained.html @@ -74,7 +74,7 @@ Guides diff --git a/docs/guides/what-is-memory-pressure-on-mac.html b/docs/guides/what-is-memory-pressure-on-mac.html index b7f4ff9..dd6d577 100644 --- a/docs/guides/what-is-memory-pressure-on-mac.html +++ b/docs/guides/what-is-memory-pressure-on-mac.html @@ -74,7 +74,7 @@ Guides diff --git a/docs/index.html b/docs/index.html index 719008e..2a261eb 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,7 +4,7 @@ Boost for Mac: a free, open source memory and disk utility - + @@ -14,7 +14,7 @@ - + @@ -57,7 +57,7 @@ Install @@ -71,7 +71,7 @@
Free & open source macOS 14+ · Universal - No network access + No telemetry

@@ -81,7 +81,7 @@

-

Boost is a menu bar app that shows what your memory is actually doing, then gives back only what is genuinely idle. No fake numbers. No subscription. No network access.

+

Boost is a menu bar app that shows what your memory is actually doing, then gives back only what is genuinely idle. No fake numbers. No subscription. No telemetry.