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/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/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/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/Scripts/build.sh b/Scripts/build.sh index 8e0a5a1..2bd43ab 100755 --- a/Scripts/build.sh +++ b/Scripts/build.sh @@ -10,7 +10,14 @@ cd "$(dirname "$0")/.." # One place. The release workflow reads it from here rather than being told # separately, so a tag and a bundle cannot disagree about what they are. -VERSION="1.4.1" +VERSION="1.5.0" + +# 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" @@ -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) 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/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.