From b2ac1923d7b3e0a0612d7eb0096cf765123613ae Mon Sep 17 00:00:00 2001 From: broisnischal Date: Wed, 7 Oct 2026 19:58:12 +0545 Subject: [PATCH] feat(updates): replace the hand-rolled updater with Sparkle 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates now run through Sparkle's standard UI (checking, release notes, download progress, installing) and the app relaunches as the new version, which the old swap-script path did silently and sometimes not at all. Trust stays on our existing Ed25519 release key: SUPublicEDKey is the same public key, the release job adds a Sparkle-format signature over the DMG (sign-update now emits both), and SUVerifyUpdateBeforeExtraction checks it before the image is mounted. With the app ad-hoc signed, Sparkle accepts EdDSA alone ("Either DSA must be valid, or Apple Code Signing must be valid", SUUpdateValidator.m); the framework and its XPC services are re-signed innermost-first in package-app.sh and the executable gets an @executable_path/../Frameworks rpath. Both feeds are published: appcast.xml for Sparkle and the legacy appcast.json, so 0.18.3–0.18.5 copies still receive this release through their old updater. CFBundleVersion becomes a monotonic build number (major*10000+minor*100+patch) for Sparkle's comparison. Verified end to end on this Mac over local HTTP: 99.0.0 → 99.0.1 showed the Software Update window, swapped the bundle and relaunched (new pid), settings intact. 257 tests pass. --- .changeset/sparkle-updates.md | 10 + .github/workflows/release.yml | 80 ++-- .github/workflows/update-feed.yml | 56 ++- DISTRIBUTION.md | 48 +-- Package.resolved | 15 + Package.swift | 10 +- Sources/EWizKit/EWizLinks.swift | 6 +- Sources/EWizKit/UpdateChecker.swift | 69 ---- Sources/EWizKit/UpdateSignature.swift | 63 +++- Sources/eWiz/MenuContentView.swift | 10 +- Sources/eWiz/SettingsView.swift | 16 +- Sources/eWiz/UpdaterManager.swift | 352 +++--------------- Sources/licensetool/main.swift | 26 +- Tests/EWizKitTests/UpdateSignatureTests.swift | 54 ++- scripts/build-number.sh | 35 ++ scripts/make-appcast.sh | 120 +++++- scripts/package-app.sh | 60 ++- 17 files changed, 527 insertions(+), 503 deletions(-) create mode 100644 .changeset/sparkle-updates.md create mode 100644 Package.resolved delete mode 100644 Sources/EWizKit/UpdateChecker.swift create mode 100755 scripts/build-number.sh diff --git a/.changeset/sparkle-updates.md b/.changeset/sparkle-updates.md new file mode 100644 index 0000000..68f7773 --- /dev/null +++ b/.changeset/sparkle-updates.md @@ -0,0 +1,10 @@ +--- +"ewiz": patch +--- + +Updating eWiz now shows what's happening and comes back on its own. Updates run through +Sparkle: "Check for Updates…" (and the Update banner) open a window that checks, shows +what's new, downloads with a progress bar, installs, and relaunches the new version — +instead of the app quietly quitting and sometimes not reappearing. Checks still happen on +launch and daily. One more update goes the old way to get there: this release installs +through the previous updater, and every one after it through Sparkle. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a2e427..9ca71ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,8 +74,11 @@ jobs: ./scripts/notarize.sh "dist/eWiz-${{ steps.ver.outputs.version }}.dmg" rm -f AuthKey.p8 - # Sign the DMG for the update feed, so a copy with no Developer ID can verify the - # update and install it itself (UpdateSignature). After stapling, which changes the file. + # Sign the DMG for both update feeds with the release key (UpdateSignature): the + # Sparkle signature appcast.xml carries, which is what the app checks against + # SUPublicEDKey, and the digest + signature appcast.json carries for copies up to + # 0.18.5. After stapling, which changes the file. Outputs: sha256, signature, + # ed_signature, length. - name: Sign the update id: sig env: @@ -83,12 +86,11 @@ jobs: run: | set -euo pipefail if [ -z "${UPDATE_SIGNING_PRIVATE_KEY:-}" ]; then - echo "UPDATE_SIGNING_PRIVATE_KEY not set — the feed will carry no signature." + echo "UPDATE_SIGNING_PRIVATE_KEY not set — the feeds will carry no signature (Sparkle will refuse the update)." exit 0 fi - OUT=$(swift run -c release licensetool sign-update --file "dist/eWiz-${{ steps.ver.outputs.version }}.dmg") - echo "sha256=${OUT%% *}" >> "$GITHUB_OUTPUT" - echo "signature=${OUT#* }" >> "$GITHUB_OUTPUT" + swift run -c release licensetool sign-update --file "dist/eWiz-${{ steps.ver.outputs.version }}.dmg" \ + | while read -r name value; do echo "${name}=${value}" >> "$GITHUB_OUTPUT"; done # Always make the build downloadable from the run summary. - name: Upload build artifact @@ -97,19 +99,10 @@ jobs: name: eWiz-${{ steps.ver.outputs.version }} path: dist/eWiz-${{ steps.ver.outputs.version }}.dmg - # Generate the update feed pointing at this release's DMG asset. - - name: Generate appcast - if: startsWith(github.ref, 'refs/tags/') - env: - UPDATE_SHA256: ${{ steps.sig.outputs.sha256 }} - UPDATE_SIGNATURE: ${{ steps.sig.outputs.signature }} - run: | - DMG_URL="https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/eWiz-${{ steps.ver.outputs.version }}.dmg" - ./scripts/make-appcast.sh "${{ steps.ver.outputs.version }}" "$DMG_URL" "eWiz ${{ steps.ver.outputs.version }}" - # The release notes are this version's CHANGELOG.md section, which the # Version PR wrote. ewiz.app's changelog and download pages read them from - # the GitHub release, so the site needs no edit per release. + # the GitHub release, and Sparkle's update window shows them from the appcast, + # so the site needs no edit per release. - name: Write release notes if: startsWith(github.ref, 'refs/tags/') run: | @@ -117,7 +110,20 @@ jobs: [ -s dist/release-notes.md ] || echo "eWiz ${{ steps.ver.outputs.version }}" > dist/release-notes.md cat dist/release-notes.md - # On a tag push, attach DMG + appcast.json to a published GitHub Release. + # Generate both update feeds (appcast.xml for Sparkle, appcast.json for copies up + # to 0.18.5) pointing at this release's DMG asset. + - name: Generate appcasts + if: startsWith(github.ref, 'refs/tags/') + env: + UPDATE_SHA256: ${{ steps.sig.outputs.sha256 }} + UPDATE_SIGNATURE: ${{ steps.sig.outputs.signature }} + UPDATE_ED_SIGNATURE: ${{ steps.sig.outputs.ed_signature }} + UPDATE_LENGTH: ${{ steps.sig.outputs.length }} + run: | + DMG_URL="https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/eWiz-${{ steps.ver.outputs.version }}.dmg" + ./scripts/make-appcast.sh "${{ steps.ver.outputs.version }}" "$DMG_URL" "$(cat dist/release-notes.md)" + + # On a tag push, attach DMG + both appcasts to a published GitHub Release. - name: Publish GitHub Release if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v2 @@ -127,16 +133,18 @@ jobs: body_path: dist/release-notes.md files: | dist/eWiz-${{ steps.ver.outputs.version }}.dmg + dist/appcast.xml dist/appcast.json draft: false make_latest: true - # Point the public in-app update feed at this release. Done inline here - # rather than in a separate `release: published` workflow because releases + # Point the public in-app update feeds at this release: appcast.xml (Sparkle, + # SUFeedURL) and appcast.json (the old updater in copies up to 0.18.5). Done inline + # here rather than in a separate `release: published` workflow because releases # published by the default GITHUB_TOKEN do NOT emit an event that can # trigger another workflow — so that feed workflow never fired on its own. # RELEASES_TOKEN is a PAT with contents:write on the ewiz-releases repo. - - name: Update appcast feed + - name: Update appcast feeds if: startsWith(github.ref, 'refs/tags/') env: GH_TOKEN: ${{ secrets.RELEASES_TOKEN }} @@ -149,16 +157,26 @@ jobs: exit 0 fi FEED_REPO="stroke-app/ewiz-releases" - DMG_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/eWiz-${VERSION}.dmg" - # The appcast the release just attached, signature included. - NEW=$(python3 -c 'import json; print(json.dumps(json.load(open("dist/appcast.json")), separators=(",", ":")))') - echo "New feed: $NEW" - SHA=$(gh api "repos/${FEED_REPO}/contents/appcast.json" --jq .sha) - gh api -X PUT "repos/${FEED_REPO}/contents/appcast.json" \ - -f message="chore: update appcast to ${VERSION}" \ - -f content="$(printf '%s' "$NEW" | base64 | tr -d '\n')" \ - -f sha="$SHA" - echo "Feed updated to ${VERSION}." + # Publishes one feed file as-is; a file the repo doesn't have yet is created. + publish() { + local name="$1" path="$2" + local sha + sha=$(gh api "repos/${FEED_REPO}/contents/${name}" --jq .sha 2>/dev/null) || sha="" + if [ -n "$sha" ]; then + gh api -X PUT "repos/${FEED_REPO}/contents/${name}" \ + -f message="chore: update ${name} to ${VERSION}" \ + -f content="$(base64 < "$path" | tr -d '\n')" -f sha="$sha" + else + gh api -X PUT "repos/${FEED_REPO}/contents/${name}" \ + -f message="chore: update ${name} to ${VERSION}" \ + -f content="$(base64 < "$path" | tr -d '\n')" + fi + echo "${name} updated to ${VERSION}." + } + # The appcasts the release just attached, signatures included. + python3 -c 'import json; print(json.dumps(json.load(open("dist/appcast.json")), separators=(",", ":")))' > dist/appcast.min.json + publish appcast.xml dist/appcast.xml + publish appcast.json dist/appcast.min.json # Publish the Homebrew cask to the ewiz-releases tap, with the freshly # built DMG's sha256, so `brew install --cask` always matches the release. diff --git a/.github/workflows/update-feed.yml b/.github/workflows/update-feed.yml index 931d5de..7198358 100644 --- a/.github/workflows/update-feed.yml +++ b/.github/workflows/update-feed.yml @@ -1,8 +1,10 @@ -name: Update appcast feed +name: Update appcast feeds -# Keeps the in-app update feed (stroke-app/ewiz-releases/appcast.json) in -# sync with the latest *published* release. Runs on publish — not on tag push — -# so the DMG download URL it points at is actually live (releases start as drafts). +# Re-publishes the in-app update feeds in stroke-app/ewiz-releases from a *published* +# release's assets: appcast.xml (Sparkle, what the app reads) and appcast.json (the old +# updater in copies up to 0.18.5). The release workflow does this itself on every tag; +# this is the manual way to redo it (workflow_dispatch) or to follow a release published +# by hand. # # Requires a repo secret `RELEASES_TOKEN`: a PAT with contents:write on the # ewiz-releases repo (the default GITHUB_TOKEN can't push to another repo). @@ -24,7 +26,7 @@ jobs: update-feed: runs-on: ubuntu-latest steps: - - name: Update ewiz-releases/appcast.json + - name: Update ewiz-releases appcast.xml and appcast.json env: GH_TOKEN: ${{ secrets.RELEASES_TOKEN }} REPO_TOKEN: ${{ github.token }} @@ -41,19 +43,39 @@ jobs: DMG_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/eWiz-${VERSION}.dmg" NOTES="eWiz ${VERSION}" - # Publish the appcast the release carries, so the signature comes with it. A - # release from before signing has none; fall back to the bare entry. + # Publishes one feed file as-is; a file the repo doesn't have yet is created. + publish() { + local name="$1" path="$2" + local sha + sha=$(gh api "repos/${FEED_REPO}/contents/${name}" --jq .sha 2>/dev/null) || sha="" + if [ -n "$sha" ]; then + gh api -X PUT "repos/${FEED_REPO}/contents/${name}" \ + -f message="chore: update ${name} to ${VERSION}" \ + -f content="$(base64 < "$path" | tr -d '\n')" -f sha="$sha" + else + gh api -X PUT "repos/${FEED_REPO}/contents/${name}" \ + -f message="chore: update ${name} to ${VERSION}" \ + -f content="$(base64 < "$path" | tr -d '\n')" + fi + echo "${name} updated to ${VERSION}." + } + + # The appcasts the release carries, so the signatures come with them. + # appcast.xml: only releases built with Sparkle have one; without it the + # Sparkle feed is left alone (an older release can't be served to Sparkle). + if GH_TOKEN="$REPO_TOKEN" gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ + --pattern appcast.xml --output /tmp/appcast.xml 2>/dev/null; then + publish appcast.xml /tmp/appcast.xml + else + echo "Release $TAG has no appcast.xml — leaving the Sparkle feed as it is." + fi + + # appcast.json: a release from before signing has none; fall back to the bare entry. if GH_TOKEN="$REPO_TOKEN" gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ --pattern appcast.json --output /tmp/appcast.json 2>/dev/null; then - NEW=$(python3 -c 'import json; print(json.dumps(json.load(open("/tmp/appcast.json")), separators=(",", ":")))') + python3 -c 'import json; print(json.dumps(json.load(open("/tmp/appcast.json")), separators=(",", ":")))' > /tmp/appcast.min.json else - NEW=$(printf '{"version":"%s","url":"%s","notes":"%s"}' "$VERSION" "$DMG_URL" "$NOTES") + printf '{"version":"%s","url":"%s","notes":"%s"}' "$VERSION" "$DMG_URL" "$NOTES" > /tmp/appcast.min.json fi - echo "New feed: $NEW" - - SHA=$(gh api "repos/${FEED_REPO}/contents/appcast.json" --jq .sha) - gh api -X PUT "repos/${FEED_REPO}/contents/appcast.json" \ - -f message="chore: update appcast to ${VERSION}" \ - -f content="$(printf '%s' "$NEW" | base64 | tr -d '\n')" \ - -f sha="$SHA" - echo "Feed updated to ${VERSION}." + cat /tmp/appcast.min.json; echo + publish appcast.json /tmp/appcast.min.json diff --git a/DISTRIBUTION.md b/DISTRIBUTION.md index 83ef3d4..9d4739e 100644 --- a/DISTRIBUTION.md +++ b/DISTRIBUTION.md @@ -117,29 +117,31 @@ reference / a future free tier. ## 6. Auto-update -eWiz checks a **public JSON feed** (`appcast.json`) on launch + daily and shows -an in-app "Update available" banner with a one-click download. - -- Feed format: `{ "version": "0.2.0", "url": "https://…/eWiz-0.2.0.dmg", "notes": "…" }` -- The app reads `UpdaterManager.feedURL` (currently - `raw.githubusercontent.com/stroke-app/ewiz-releases/main/appcast.json`). -- The release workflow generates `dist/appcast.json` (via `scripts/make-appcast.sh`) - and attaches it to the GitHub Release. - -**Hosting (required, because the source repo is private):** create a **public** -place for the feed + DMGs so users can reach them without auth. Easiest options: -- A public `ewiz-releases` repo: commit `appcast.json` there and upload DMGs - to its Releases. Point `feedURL` at its `raw.githubusercontent.com/...` path. -- GitHub Pages, or your storefront/CDN. - -Then per release: build → upload the DMG to the public location → update -`appcast.json` there with the new version/url. - -**Full silent install (future):** for true "download + install + relaunch" with no -manual drag, integrate **Sparkle** (`sparkle-project/Sparkle`). It needs Apple -notarization and bundling Sparkle's XPC services into the `.app` — worth doing once -you're enrolled and notarizing. The current updater is the no-notarization-needed -stepping stone. +eWiz updates through **Sparkle 2** (a SwiftPM dependency): a check on launch and daily +(`SUEnableAutomaticChecks`, `SUScheduledCheckInterval` = 86400), or from About › "Check +for Updates…" and the menu's Update banner. Sparkle's window shows the check, the notes, +the download's progress and the install, then relaunches the new version. + +- The app reads `SUFeedURL` from Info.plist (written by `scripts/package-app.sh`): + `raw.githubusercontent.com/stroke-app/ewiz-releases/main/appcast.xml`. +- **Trust** is the EdDSA key, not a Developer ID: `SUPublicEDKey` in Info.plist is + `UpdateSignature.publicKeyBase64`, and every DMG in the feed carries that key's + `sparkle:edSignature`, made by `licensetool sign-update` from the + `UPDATE_SIGNING_PRIVATE_KEY` secret (a 32-byte Ed25519 seed, base64 — the same format + Sparkle's `generate_keys`/`sign_update` use). The app is ad-hoc signed; Sparkle accepts an + update when *either* the EdDSA signature *or* the Apple code-signing match holds, so + EdDSA alone carries it. Rotating the key means signing the release that introduces the + new key with the old one too; dropping it isn't supported. +- `CFBundleVersion` is a build number derived from the version (`scripts/build-number.sh`: + `0.18.5` → `1805`), and the appcast's `sparkle:version` is the same number. +- `scripts/package-app.sh` copies `Sparkle.framework` from the package artifact into + `Contents/Frameworks`, links the app with an `@executable_path/../Frameworks` rpath, and + re-signs the framework's XPC services, Autoupdate and Updater.app innermost-first. +- `scripts/make-appcast.sh` writes `dist/appcast.xml` **and** `dist/appcast.json`. The JSON + feed is what copies up to 0.18.5 poll with the app's old updater (`{version, url, notes, + sha256, signature}`, signature over the digest's hex); they can only reach a Sparkle build + through it, so the release workflow keeps publishing both to `stroke-app/ewiz-releases`. + It can be retired once those copies have moved on. ## 7. Icons & branding (later) diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..e4b5999 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "252ecd9f2af745e66db1f63a9649d707f46f3fbe7995bf273f2d2fe87ad07c48", + "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 91097c2..d1bf850 100644 --- a/Package.swift +++ b/Package.swift @@ -12,6 +12,11 @@ let package = Package( .executable(name: "ewiz-mcp", targets: ["ewiz-mcp"]), .executable(name: "licensetool", targets: ["licensetool"]) ], + dependencies: [ + // In-app updates: progress window, install, relaunch. The EdDSA key in the app's + // Info.plist (SUPublicEDKey) is what secures the download; see scripts/package-app.sh. + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.10.0") + ], targets: [ // Low-level SMC access (C). Requires root to *write*. .target( @@ -35,7 +40,10 @@ let package = Package( // The menu bar GUI app (runs as the user). .executableTarget( name: "eWiz", - dependencies: ["EWizKit"], + dependencies: [ + "EWizKit", + .product(name: "Sparkle", package: "Sparkle") + ], path: "Sources/eWiz", linkerSettings: [ // Wi-Fi power control. diff --git a/Sources/EWizKit/EWizLinks.swift b/Sources/EWizKit/EWizLinks.swift index 903d3b7..d0fc754 100644 --- a/Sources/EWizKit/EWizLinks.swift +++ b/Sources/EWizKit/EWizLinks.swift @@ -26,8 +26,10 @@ public enum EWizLinks { public static let source = URL(string: "https://github.com/stroke-app/ewiz")! public static let newIssue = URL(string: "https://github.com/stroke-app/ewiz/issues/new")! - /// The update feed. On GitHub rather than the website: CI publishes it on every - /// release, and installs from before the rename still read it at its old address. + /// The update feed copies up to 0.18.5 poll with the app's old updater. The app itself + /// now updates through Sparkle, whose feed (appcast.xml, next to this one) is named in + /// Info.plist as SUFeedURL; CI publishes both on every release, this one until those + /// copies have moved on. public static let updateFeed = URL(string: "https://raw.githubusercontent.com/stroke-app/ewiz-releases/main/appcast.json")! } diff --git a/Sources/EWizKit/UpdateChecker.swift b/Sources/EWizKit/UpdateChecker.swift deleted file mode 100644 index 97125d2..0000000 --- a/Sources/EWizKit/UpdateChecker.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Foundation - -/// An available newer release, parsed from the update feed. -public struct AppUpdate: Sendable, Equatable { - public let version: String - public let url: URL // DMG download - public let notes: String - /// The DMG's SHA-256 and the release key's signature over it, when the release was - /// signed. Together they let an ad-hoc build install the update itself; see - /// `UpdateSignature`. Absent from feeds written before signing existed. - public let sha256: String? - public let signature: String? - public init(version: String, url: URL, notes: String, - sha256: String? = nil, signature: String? = nil) { - self.version = version - self.url = url - self.notes = notes - self.sha256 = sha256 - self.signature = signature - } - - /// Whether this release can be checked without a Developer ID. - public var isSigned: Bool { sha256 != nil && signature != nil } -} - -/// Checks a public JSON "appcast" for a newer version. The feed looks like: -/// { "version": "0.2.0", -/// "url": "https://…/eWiz-0.2.0.dmg", -/// "notes": "What's new…" } -public enum UpdateChecker { - - public static func check(feedURL: URL, currentVersion: String) async throws -> AppUpdate? { - var req = URLRequest(url: feedURL) - req.cachePolicy = .reloadIgnoringLocalCacheData - let (data, _) = try await URLSession.shared.data(for: req) - - let feed = try decode(data) - guard isNewer(feed.version, than: currentVersion) else { return nil } - return feed - } - - /// The feed's contents. Unknown fields are ignored, so a feed can grow without breaking - /// the copies already installed. - public static func decode(_ data: Data) throws -> AppUpdate { - struct Feed: Decodable { - let version: String; let url: URL; let notes: String? - let sha256: String?; let signature: String? - } - let feed = try JSONDecoder().decode(Feed.self, from: data) - return AppUpdate(version: feed.version, url: feed.url, notes: feed.notes ?? "", - sha256: feed.sha256, signature: feed.signature) - } - - /// Numeric semver comparison: "0.2.0" > "0.1.9". Non-numeric parts are ignored. - public static func isNewer(_ remote: String, than current: String) -> Bool { - let r = parts(remote), c = parts(current) - for i in 0.. b } - } - return false - } - - private static func parts(_ v: String) -> [Int] { - v.split(whereSeparator: { $0 == "." || $0 == "-" }) - .map { Int($0.prefix { $0.isNumber }) ?? 0 } - } -} diff --git a/Sources/EWizKit/UpdateSignature.swift b/Sources/EWizKit/UpdateSignature.swift index 92df25f..477d45f 100644 --- a/Sources/EWizKit/UpdateSignature.swift +++ b/Sources/EWizKit/UpdateSignature.swift @@ -1,20 +1,29 @@ import CryptoKit import Foundation -/// Ed25519 signatures over release disk images: how a build with no Apple Developer ID -/// still checks its own update before swapping it in. +/// Ed25519 signatures over release disk images, in the two forms the feeds carry. /// -/// The updater used to have one way to trust a download: the replacement had to carry the -/// same Developer ID team as the running app. eWiz is signed ad hoc, so there was no team, -/// and every update stopped at "won't replace itself automatically" and a browser -/// download. This is the Sparkle model instead: the release job signs the DMG's SHA-256 -/// with a key only CI holds, the feed carries the digest and signature, and the app checks -/// both against the public key below before it touches the bundle. +/// Sparkle installs updates now, and what it checks is `sparkle:edSignature` in +/// appcast.xml: the release key's Ed25519 signature over the DMG's bytes, checked against +/// `SUPublicEDKey` in Info.plist (`publicKeyBase64` below). That is the only thing standing +/// between the feed and the running app, because an ad-hoc build has no Developer ID for +/// Sparkle to match. `sparkleSignature(fileAt:)` produces it with the same key Sparkle's +/// own `sign_update` would take (a 32-byte Ed25519 seed, base64) over the same input, so +/// the release job needs no extra binary. The bytes differ from `sign_update`'s — CryptoKit +/// randomizes Ed25519 signatures, Sparkle's implementation is deterministic — and each +/// verifies under the other's public key; that was checked both ways against the +/// `sign_update` in Sparkle 2.10.0 before this shipped. +/// +/// Before Sparkle, the app's own updater read appcast.json, whose `signature` is the same +/// key's signature over the DMG's SHA-256 *as a hex string*, not over the bytes. Copies up +/// to 0.18.5 still poll that feed, and they can only reach a Sparkle build through it, so +/// `sign(fileAt:)`/`verify(fileAt:)` stay and CI keeps publishing both feeds. public enum UpdateSignature { - /// Public half of the release signing key. The private half is the - /// `UPDATE_SIGNING_PRIVATE_KEY` repo secret (a copy sits in the maintainer's Keychain as - /// "eWiz update signing key"). Replacing it means every installed copy from before the - /// change stops trusting new releases until it's updated by hand once. + /// Public half of the release signing key, and the app's `SUPublicEDKey`. The private + /// half is the `UPDATE_SIGNING_PRIVATE_KEY` repo secret, the base64 of the 32-byte seed + /// (a copy sits in the maintainer's Keychain as "eWiz update signing key"). Sparkle only + /// supports rotating this key, never dropping it, and a rotated key has to sign the + /// release that introduces it with the old key too. public static let publicKeyBase64 = "cDHo+nn97ixS/aCGAdfFWXfdCMd/oc3EO3CCUonSVgc=" public enum Failure: Error, Equatable { @@ -57,4 +66,34 @@ public enum UpdateSignature { throw Failure.badSignature } } + + // MARK: - Sparkle (appcast.xml) + + /// What Sparkle's `sign_update` prints: the Ed25519 signature over the file's bytes, + /// base64, and the file's length, for the enclosure's `sparkle:edSignature` and + /// `length`. The whole image is read into memory, as `sign_update` does; release + /// tooling only. + public static func sparkleSignature(fileAt url: URL, privateKeyBase64: String) throws + -> (edSignature: String, length: Int) + { + guard let raw = Data(base64Encoded: privateKeyBase64), + let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: raw) else { + throw Failure.badSignature + } + guard let data = try? Data(contentsOf: url) else { throw Failure.unreadable } + return (try key.signature(for: data).base64EncodedString(), data.count) + } + + /// Checks a `sparkle:edSignature` the way Sparkle will: against the file's bytes and + /// `SUPublicEDKey`. + public static func verifySparkleSignature(fileAt url: URL, edSignature: String, + publicKeyBase64: String = publicKeyBase64) throws { + guard let data = try? Data(contentsOf: url) else { throw Failure.unreadable } + guard let raw = Data(base64Encoded: publicKeyBase64), + let key = try? Curve25519.Signing.PublicKey(rawRepresentation: raw), + let sig = Data(base64Encoded: edSignature), + key.isValidSignature(sig, for: data) else { + throw Failure.badSignature + } + } } diff --git a/Sources/eWiz/MenuContentView.swift b/Sources/eWiz/MenuContentView.swift index 51d8b32..a2b2618 100644 --- a/Sources/eWiz/MenuContentView.swift +++ b/Sources/eWiz/MenuContentView.swift @@ -56,7 +56,7 @@ struct MenuContentView: View { .padding(.top, DS.Space.m) .padding(.bottom, DS.Space.m) - if let update = updater.available { notice { updateBanner(update) } } + if let version = updater.availableVersion { notice { updateBanner(version) } } if !license.isLicensed { notice { licenseBanner } } if restDue { notice { restBanner } } @@ -132,19 +132,19 @@ struct MenuContentView: View { // MARK: - Update banner - private func updateBanner(_ update: AppUpdate) -> some View { + /// "Update" hands over to Sparkle's window, which downloads, installs and relaunches. + private func updateBanner(_ version: String) -> some View { HStack(spacing: DS.Space.s) { HugeIcon("download", size: DS.Icon.row).foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 1) { - Text("Update available: v\(update.version)") + Text("Update available: v\(version)") .font(DS.Typo.rowTitle.weight(.medium)) Text("You have v\(updater.currentVersion)") .font(DS.Typo.rowCaption).foregroundStyle(.secondary) } Spacer(minLength: DS.Space.s) - Button(updater.installing ? "Installing…" : "Update") { updater.installUpdate() } + Button("Update") { updater.checkForUpdates() } .controlSize(.small) - .disabled(updater.installing) } } diff --git a/Sources/eWiz/SettingsView.swift b/Sources/eWiz/SettingsView.swift index e253f80..0dfb5be 100644 --- a/Sources/eWiz/SettingsView.swift +++ b/Sources/eWiz/SettingsView.swift @@ -1444,27 +1444,23 @@ struct SettingsView: View { } card("Updates") { - if let update = updater.available { + // Both buttons open Sparkle's window; it shows progress, installs, relaunches. + if let version = updater.availableVersion { HStack(alignment: .center) { VStack(alignment: .leading, spacing: 2) { - Text("Update available · v\(update.version)") + Text("Update available · v\(version)") .font(.callout.weight(.medium)) Text("You have v\(updater.currentVersion)") .font(.caption).foregroundStyle(.secondary) } Spacer() - Button(updater.installing ? "Installing…" : "Update Now") { - updater.installUpdate() - } - .disabled(updater.installing) + Button("Update Now") { updater.checkForUpdates() } } .padding(.horizontal, rowInset).padding(.vertical, DS.Space.s + 2) } else { HStack { - Button(updater.checking ? "Checking…" : "Check for Updates…") { - updater.check(userInitiated: true) - } - .disabled(updater.checking) + Button("Check for Updates…") { updater.checkForUpdates() } + .disabled(!updater.canCheckForUpdates) Spacer() Text("v\(updater.currentVersion)") .font(.callout).foregroundStyle(.secondary).monospacedDigit() diff --git a/Sources/eWiz/UpdaterManager.swift b/Sources/eWiz/UpdaterManager.swift index 9bfdd22..d08db9a 100644 --- a/Sources/eWiz/UpdaterManager.swift +++ b/Sources/eWiz/UpdaterManager.swift @@ -1,323 +1,67 @@ -import Foundation import Combine -import AppKit -import Security -import EWizKit - -/// In-app update checks against a public JSON feed: on launch, daily, and on demand. +import Foundation +import Sparkle + +/// In-app updates, through Sparkle: a check on launch and daily, or on demand; then its +/// window takes over (checking → what's new → download progress → installing), the app +/// quits, and the new version comes back up. This is the thin layer SwiftUI observes: +/// whether a check can start, and whether the last one found something. +/// +/// Everything that decides whether an update is trusted lives in Info.plist and +/// `UpdateSignature`: `SUPublicEDKey` is the release key, and Sparkle refuses a download +/// whose `sparkle:edSignature` the key doesn't verify. The app is ad-hoc signed, so that +/// signature is the whole chain of trust. Sparkle allows that: its Developer ID match is +/// one of two accepted proofs ("Either DSA must be valid, or Apple Code Signing must be +/// valid" — SUUpdateValidator.m), and with no team on either side only EdDSA can pass. @MainActor final class UpdaterManager: ObservableObject { - @Published private(set) var available: AppUpdate? - @Published private(set) var checking = false - @Published private(set) var installing = false - @Published private(set) var lastResult: String? - - /// Public update feed (any host reachable without auth). - let feedURL = EWizLinks.updateFeed + /// Whether "Check for Updates…" can start a check right now; false while one runs. + @Published private(set) var canCheckForUpdates = false + /// The version the last check found, for the banners; nil once the app is up to date. + @Published private(set) var availableVersion: String? let currentVersion: String = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.0" - private var timer: Timer? + private let controller: SPUStandardUpdaterController + private let observer = UpdateObserver() init() { - check(userInitiated: false) - let t = Timer(timeInterval: 24 * 3600, repeats: true) { [weak self] _ in - Task { @MainActor in self?.check(userInitiated: false) } + controller = SPUStandardUpdaterController(startingUpdater: false, + updaterDelegate: observer, + userDriverDelegate: nil) + observer.onChange = { [weak self] version in self?.availableVersion = version } + controller.updater.publisher(for: \.canCheckForUpdates) + .assign(to: &$canCheckForUpdates) + + // Sparkle reads SUFeedURL and SUPublicEDKey from the bundle, which only the + // packaged app has (scripts/package-app.sh writes them). A bare `swift run` binary + // would get a "misconfigured" alert a second after launch; leave its updater + // unstarted instead, so the buttons stay disabled and nothing else changes. + if Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") != nil { + controller.startUpdater() } - t.tolerance = 3600 // daily check; an hour of slack lets the OS batch it - RunLoop.main.add(t, forMode: .common) - timer = t } - func check(userInitiated: Bool) { - guard !checking else { return } - checking = true - let feed = feedURL - let current = currentVersion - Task.detached { - var found: AppUpdate? - var message: String? - do { - found = try await UpdateChecker.check(feedURL: feed, currentVersion: current) - message = found == nil ? "You're up to date (v\(current))." : nil - } catch { - message = "Couldn't check for updates." - } - let result = found - let msg = message - await MainActor.run { - self.available = result - self.lastResult = msg - self.checking = false - if userInitiated, result == nil { - self.showInfoAlert(msg ?? "You're up to date.") - } - } - } + /// A check the user asked for. Sparkle's window handles it from here, and says so + /// when there is nothing new. While an update is already waiting (the banner), this + /// brings that window forward rather than starting over. + func checkForUpdates() { + controller.checkForUpdates(nil) } +} - /// Open the DMG in the browser — manual fallback when the in-place installer can't run. - func downloadAvailable() { - guard let u = available?.url else { return } - NSWorkspace.shared.open(u) - } - - /// Download and install the update in place, then relaunch. Falls back to opening - /// the DMG if the automatic path is blocked (e.g. app lives somewhere unwritable). - func installUpdate() { - guard !installing, let update = available else { return } - let url = update.url - let bundlePath = Bundle.main.bundlePath - let parent = (bundlePath as NSString).deletingLastPathComponent - - // Fail fast (before we download or quit) if we can't replace the bundle. - guard FileManager.default.isWritableFile(atPath: parent) else { - showInfoAlert("eWiz can't update itself here because \(parent) isn't writable. Opening the download so you can install it manually.") - downloadAvailable() - return - } - - // Two ways to trust a download: a Developer ID team for the replacement to match, - // or the release key's signature in the feed (`UpdateSignature`), which is what an - // ad-hoc build has. With neither there is nothing to check the replacement against, - // and a check against nothing is decoration: hand it to the browser instead. - let team = HelperService.teamIdentifier - guard team != nil || update.isSigned else { - showInfoAlert("This update isn't signed, so eWiz can't check it before installing it. Opening the download so you can install it yourself.") - downloadAvailable() - return - } - - installing = true - lastResult = nil - let pid = ProcessInfo.processInfo.processIdentifier - let bundleID = Bundle.main.bundleIdentifier ?? "com.ewiz.app" - Task.detached { - do { - try await Self.performInstall(from: url, bundlePath: bundlePath, pid: pid, - bundleID: bundleID, team: team, expected: update) - // The swap script now waits for us to quit, then relaunches. - await MainActor.run { NSApplication.shared.terminate(nil) } - } catch { - await MainActor.run { - self.installing = false - self.showInfoAlert("Couldn't install the update automatically (\(error.localizedDescription)). Opening the download so you can install it manually.") - self.downloadAvailable() - } - } - } - } - - // MARK: - In-place install (Sparkle-lite) - - private enum UpdaterError: LocalizedError { - case appNotFoundInDMG - case rejected(String) - case tool(String, String) - - var errorDescription: String? { - switch self { - case .appNotFoundInDMG: return "the update disk image didn't contain eWiz.app" - case .rejected(let why): return "the downloaded update was rejected: \(why)" - case .tool(let name, let msg): - let detail = msg.trimmingCharacters(in: .whitespacesAndNewlines) - return "\(name) failed" + (detail.isEmpty ? "" : ": \(detail)") - } - } - } - - // MARK: - Trust - - /// Refuse to install anything not signed by the team that signed what's running. - /// - /// This is the check the helper's own update path has had all along, and the reason it - /// gives applies with more force here: `HelperUpdate` says "a path from a client is a - /// claim, not evidence", and a URL out of a JSON feed is exactly the same kind of - /// claim. Up to now the only thing standing between that feed and `ditto` over the - /// running app was TLS to raw.githubusercontent.com — and the script then stripped - /// `com.apple.quarantine`, so Gatekeeper never looked at the result either. Whoever - /// could write to the release repo could replace the app on every install, silently. - /// - /// `anchor apple generic` pins the chain to Apple's Developer ID root, so a self-signed - /// certificate carrying the right OU doesn't pass. Evaluated by the Security framework - /// against the bundle on disk, not against anything the feed said about it. - nonisolated private static func verify(bundleAt path: String, matchesTeam team: String) throws { - var candidate: SecStaticCode? - guard SecStaticCodeCreateWithPath(URL(fileURLWithPath: path) as CFURL, [], &candidate) - == errSecSuccess, let candidate else { - throw UpdaterError.rejected("the update has no readable code signature") - } - - var requirement: SecRequirement? - let text = "anchor apple generic and certificate leaf[subject.OU] = \"\(team)\"" - guard SecRequirementCreateWithString(text as CFString, [], &requirement) == errSecSuccess, - let requirement else { - throw UpdaterError.rejected("could not build a signing requirement") - } - - var error: Unmanaged? - let status = SecStaticCodeCheckValidityWithErrors( - candidate, SecCSFlags(rawValue: kSecCSCheckAllArchitectures), requirement, &error) - guard status == errSecSuccess else { - throw UpdaterError.rejected(error?.takeRetainedValue().localizedDescription - ?? "it isn't signed by team \(team)") - } - } - - /// Download, mount, and hand off to a detached script that waits for this process - /// to exit, swaps the bundle, and relaunches. Off the main actor — local files only. - nonisolated private static func performInstall(from url: URL, bundlePath: String, pid: Int32, - bundleID: String, team: String?, - expected: AppUpdate) async throws { - let fm = FileManager.default - let tmp = NSTemporaryDirectory() - let stamp = UUID().uuidString - - // 1. Download the DMG to a stable temp path. - let (downloaded, _) = try await URLSession.shared.download(from: url) - let dmgPath = tmp + "ewiz-update-\(stamp).dmg" - try? fm.removeItem(atPath: dmgPath) - try fm.moveItem(atPath: downloaded.path, toPath: dmgPath) - - // 1a. Signed releases are checked before the image is even mounted: the digest has - // to match and the signature has to be the release key's. - if let sha = expected.sha256, let signature = expected.signature { - do { - try UpdateSignature.verify(fileAt: URL(fileURLWithPath: dmgPath), sha256: sha, - signature: signature) - } catch { - try? fm.removeItem(atPath: dmgPath) - throw UpdaterError.rejected("it doesn't match the signature eWiz releases carry") - } - } - - // 2. Mount it on a private, non-browsable mount point. - let mountPoint = tmp + "ewiz-mnt-\(stamp)" - try fm.createDirectory(atPath: mountPoint, withIntermediateDirectories: true) - try runTool("/usr/bin/hdiutil", - ["attach", dmgPath, "-nobrowse", "-noverify", "-mountpoint", mountPoint]) - - // 3. Locate the .app inside the image. - let appName = (bundlePath as NSString).lastPathComponent // e.g. "eWiz.app" - let srcApp = mountPoint + "/" + appName - guard fm.fileExists(atPath: srcApp) else { - try? runTool("/usr/bin/hdiutil", ["detach", mountPoint, "-quiet"]) - throw UpdaterError.appNotFoundInDMG - } - - // 3a. Before a swap script is even written. Everything past this point runs - // detached, after this process has exited, with nobody left to refuse. - do { - if let team { - try verify(bundleAt: srcApp, matchesTeam: team) - } else { - // Signed release, ad-hoc app: the image was verified above. Still refuse - // anything that isn't this app, so a signed image can't swap in another. - let id = NSDictionary(contentsOfFile: srcApp + "/Contents/Info.plist")?["CFBundleIdentifier"] as? String - guard id == bundleID else { - throw UpdaterError.rejected("it contains \(id ?? "an app with no bundle ID"), not \(bundleID)") - } - } - } catch { - try? runTool("/usr/bin/hdiutil", ["detach", mountPoint, "-quiet"]) - try? fm.removeItem(atPath: dmgPath) - throw error - } - - // 4. Swap-and-relaunch script: waits for THIS pid to exit so it never overwrites - // a running bundle, keeps a .bak to roll back, refreshes Launch Services (else - // the new bundle is shadowed by a stale registration). Output goes to a log - // file so the parent's closing pipes can't SIGPIPE it. - let logPath = tmp + "ewiz-update.log" - let script = """ - #!/bin/bash - exec >>"\(logPath)" 2>&1 - echo "=== $(date) eWiz updater (pid \(pid)) ===" - while /bin/kill -0 \(pid) 2>/dev/null; do /bin/sleep 0.2; done - echo "app exited; installing update" - if /usr/bin/ditto "\(srcApp)" "\(bundlePath).new"; then - /usr/bin/xattr -dr com.apple.quarantine "\(bundlePath).new" 2>/dev/null || true - /bin/rm -rf "\(bundlePath).bak" - # This `mv` has to succeed before the next one runs. `mv new old` where `old` is - # still a directory does not fail — it moves `new` *inside* it — so a tolerated - # failure here left a eWiz.app/eWiz.app.new, printed "swap ok" and threw - # the backup away. - if /bin/mv "\(bundlePath)" "\(bundlePath).bak"; then - if /bin/mv "\(bundlePath).new" "\(bundlePath)"; then - echo "swap ok" - /bin/rm -rf "\(bundlePath).bak" - else - echo "swap failed; restoring backup" - /bin/rm -rf "\(bundlePath)" - /bin/mv "\(bundlePath).bak" "\(bundlePath)" 2>/dev/null || true - fi - else - echo "could not move the old bundle aside; leaving it where it is" - /bin/rm -rf "\(bundlePath).new" - fi - else - echo "ditto failed" - fi - /usr/bin/xattr -dr com.apple.quarantine "\(bundlePath)" 2>/dev/null || true - /usr/bin/hdiutil detach "\(mountPoint)" -quiet 2>/dev/null || /usr/bin/hdiutil detach "\(mountPoint)" -force 2>/dev/null || true - /bin/rm -f "\(dmgPath)" - /bin/rmdir "\(mountPoint)" 2>/dev/null || true - LSREG="/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister" - "$LSREG" -f "\(bundlePath)" 2>/dev/null || true - # Give LaunchServices a moment to settle after the swap+re-register, then - # relaunch with verification: `open` occasionally reports success without - # actually bringing the app up (freshly-swapped bundle, lingering launch - # state). Retry until the process is visible, checking first so we never - # spawn a duplicate. `-n` forces a new instance rather than trying to - # activate a stale record of the app that just quit; fall back to a plain - # open and finally a launch by bundle id. - EXEC="\(bundlePath)/Contents/MacOS" - /bin/sleep 1 - for i in 1 2 3 4 5 6; do - if /usr/bin/pgrep -f "$EXEC/" >/dev/null 2>&1; then echo "relaunch confirmed (try $i)"; break; fi - echo "relaunch attempt $i" - /usr/bin/open -n "\(bundlePath)" 2>/dev/null \ - || /usr/bin/open "\(bundlePath)" 2>/dev/null \ - || /usr/bin/open -b "\(bundleID)" 2>/dev/null || true - /bin/sleep 1.5 - done - /usr/bin/pgrep -f "$EXEC/" >/dev/null 2>&1 || echo "warning: app not visibly running after relaunch attempts" - echo "done" - /bin/rm -f "$0" - """ - let scriptPath = tmp + "ewiz-update-\(stamp).sh" - try script.write(toFile: scriptPath, atomically: true, encoding: .utf8) +/// Sparkle's delegate, apart from the manager because the updater takes its delegate when +/// it's created, before `self` exists. Sparkle calls these on the main thread. +private final class UpdateObserver: NSObject, SPUUpdaterDelegate { + var onChange: (@MainActor (String?) -> Void)? - // 5. Launch fully detached (nohup) so it survives this app terminating — a direct - // child can be torn down with the parent before finishing the swap/relaunch. - let p = Process() - p.executableURL = URL(fileURLWithPath: "/bin/bash") - p.arguments = ["-c", "/usr/bin/nohup /bin/bash \"\(scriptPath)\" >/dev/null 2>&1 &"] - try p.run() - } - - nonisolated private static func runTool(_ path: String, _ args: [String]) throws { - let p = Process() - p.executableURL = URL(fileURLWithPath: path) - p.arguments = args - let err = Pipe() - p.standardError = err - p.standardOutput = Pipe() - try p.run() - p.waitUntilExit() - if p.terminationStatus != 0 { - let msg = String(decoding: err.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) - throw UpdaterError.tool((path as NSString).lastPathComponent, msg) - } + func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { + let version = item.displayVersionString + MainActor.assumeIsolated { onChange?(version) } } - private func showInfoAlert(_ text: String) { - NSApplication.shared.activate(ignoringOtherApps: true) - let alert = NSAlert() - alert.messageText = "eWiz" - alert.informativeText = text - alert.addButton(withTitle: "OK") - alert.runModal() + func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) { + MainActor.assumeIsolated { onChange?(nil) } } } diff --git a/Sources/licensetool/main.swift b/Sources/licensetool/main.swift index 547c358..ce9811f 100644 --- a/Sources/licensetool/main.swift +++ b/Sources/licensetool/main.swift @@ -55,8 +55,13 @@ case "sign": } case "sign-update": - // Release tooling: sign a DMG for the update feed. The key comes from + // Release tooling: sign a DMG for both update feeds. The key comes from // UPDATE_SIGNING_PRIVATE_KEY, so it never sits on a command line or in a log. + // Prints one `name value` pair per line: + // sha256 the DMG's digest (appcast.json, Homebrew cask) + // signature signature over the digest's hex (appcast.json, copies ≤ 0.18.5) + // ed_signature Sparkle's signature over the bytes (appcast.xml sparkle:edSignature) + // length the DMG's size in bytes (appcast.xml length) guard let path = value("--file"), let priv = ProcessInfo.processInfo.environment["UPDATE_SIGNING_PRIVATE_KEY"], !priv.isEmpty else { FileHandle.standardError.write(Data( @@ -64,12 +69,17 @@ case "sign-update": exit(64) } do { - let signed = try UpdateSignature.sign(fileAt: URL(fileURLWithPath: path), privateKeyBase64: priv) - // Checked against the key the app ships with before it's printed, so a release - // signed with the wrong key fails here rather than on every user's Mac. - try UpdateSignature.verify(fileAt: URL(fileURLWithPath: path), sha256: signed.sha256, - signature: signed.signature) - print("\(signed.sha256) \(signed.signature)") + let file = URL(fileURLWithPath: path) + let signed = try UpdateSignature.sign(fileAt: file, privateKeyBase64: priv) + let sparkle = try UpdateSignature.sparkleSignature(fileAt: file, privateKeyBase64: priv) + // Checked against the key the app ships with before anything is printed, so a + // release signed with the wrong key fails here rather than on every user's Mac. + try UpdateSignature.verify(fileAt: file, sha256: signed.sha256, signature: signed.signature) + try UpdateSignature.verifySparkleSignature(fileAt: file, edSignature: sparkle.edSignature) + print("sha256 \(signed.sha256)") + print("signature \(signed.signature)") + print("ed_signature \(sparkle.edSignature)") + print("length \(sparkle.length)") } catch { FileHandle.standardError.write(Data("error: \(error) (is this the key matching UpdateSignature.publicKeyBase64?)\n".utf8)) exit(1) @@ -106,7 +116,7 @@ default: licensetool — eWiz license keys genkey Generate an Ed25519 keypair - sign-update --file Sign a release DMG for the update feed + sign-update --file Sign a release DMG for both update feeds (key in UPDATE_SIGNING_PRIVATE_KEY) sign --priv --email Mint a license token bound to one Mac --device (device code shown in the app's license window) diff --git a/Tests/EWizKitTests/UpdateSignatureTests.swift b/Tests/EWizKitTests/UpdateSignatureTests.swift index bdee342..6a3ff81 100644 --- a/Tests/EWizKitTests/UpdateSignatureTests.swift +++ b/Tests/EWizKitTests/UpdateSignatureTests.swift @@ -52,11 +52,53 @@ struct UpdateSignatureTests { _ = try Curve25519.Signing.PublicKey(rawRepresentation: raw) } - @Test("Feeds with and without a signature both decode") - func feedDecoding() throws { - let signed = try UpdateChecker.decode(Data(#"{"version":"0.18.3","url":"https://x/eWiz-0.18.3.dmg","notes":"n","sha256":"ab","signature":"cd"}"#.utf8)) - #expect(signed.isSigned) - let bare = try UpdateChecker.decode(Data(#"{"version":"0.18.2","url":"https://x/eWiz-0.18.2.dmg","notes":"n"}"#.utf8)) - #expect(!bare.isSigned) + // MARK: Sparkle + + /// `sparkle:edSignature` is Ed25519 over the file's bytes, which is what Sparkle checks + /// against `SUPublicEDKey`: verify it the way Sparkle does, with nothing but the public + /// key and the bytes. + @Test("The Sparkle signature is Ed25519 over the file's bytes") + func sparkleSignature() throws { + let bytes = "eWiz 0.19.0" + let dmg = file(bytes) + let signed = try UpdateSignature.sparkleSignature(fileAt: dmg, privateKeyBase64: priv) + #expect(signed.length == bytes.utf8.count) + let sig = try #require(Data(base64Encoded: signed.edSignature)) + #expect(sig.count == 64) + #expect(key.publicKey.isValidSignature(sig, for: Data(bytes.utf8))) + try UpdateSignature.verifySparkleSignature(fileAt: dmg, edSignature: signed.edSignature, + publicKeyBase64: pub) + } + + @Test("A Sparkle signature from another key, or over other bytes, is refused") + func sparkleSignatureRefused() throws { + let dmg = file("eWiz 0.19.0") + let forged = try UpdateSignature.sparkleSignature( + fileAt: dmg, + privateKeyBase64: Curve25519.Signing.PrivateKey().rawRepresentation.base64EncodedString()) + #expect(throws: UpdateSignature.Failure.badSignature) { + try UpdateSignature.verifySparkleSignature(fileAt: dmg, edSignature: forged.edSignature, + publicKeyBase64: pub) + } + let genuine = try UpdateSignature.sparkleSignature(fileAt: dmg, privateKeyBase64: priv) + #expect(throws: UpdateSignature.Failure.badSignature) { + try UpdateSignature.verifySparkleSignature(fileAt: file("something else"), + edSignature: genuine.edSignature, + publicKeyBase64: pub) + } + } + + /// The legacy feed signs the digest's hex, Sparkle signs the bytes: two different + /// signatures from one key, and neither verifies as the other. + @Test("The two feed signatures are distinct") + func distinctSignatures() throws { + let dmg = file("eWiz 0.19.0") + let legacy = try UpdateSignature.sign(fileAt: dmg, privateKeyBase64: priv) + let sparkle = try UpdateSignature.sparkleSignature(fileAt: dmg, privateKeyBase64: priv) + #expect(legacy.signature != sparkle.edSignature) + #expect(throws: UpdateSignature.Failure.badSignature) { + try UpdateSignature.verifySparkleSignature(fileAt: dmg, edSignature: legacy.signature, + publicKeyBase64: pub) + } } } diff --git a/scripts/build-number.sh b/scripts/build-number.sh new file mode 100755 index 0000000..8aac121 --- /dev/null +++ b/scripts/build-number.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Prints the build number (CFBundleVersion, and the appcast's sparkle:version) for a +# release version. Sparkle decides "is this newer?" from that number, so it has to grow +# with every release and read as one integer: major*10000 + minor*100 + patch. +# +# 0.18.5 → 1805 +# 0.19.0 → 1900 +# 1.2.3 → 10203 +# 0.19.0-beta.1 → 1900 (a pre-release suffix is ignored) +# +# Minor and patch therefore have two digits each: a 0.100.0 or 0.18.100 would collide +# with its neighbours, so the script refuses them rather than ship a version Sparkle +# would sort wrongly. scripts/package-app.sh writes it into Info.plist and +# scripts/make-appcast.sh into appcast.xml, from the same version string, so the two +# can't disagree. +# +# Usage: ./scripts/build-number.sh +set -euo pipefail + +VERSION="${1:?usage: build-number.sh }" +IFS=. read -r MAJOR MINOR PATCH _ <<<"${VERSION%%-*}" + +for part in MAJOR MINOR PATCH; do + digits="${!part:-0}" + digits="${digits//[!0-9]/}" + : "${digits:=0}" + printf -v "$part" '%d' "$((10#$digits))" +done + +if (( MINOR >= 100 || PATCH >= 100 )); then + echo "error: $VERSION has a minor or patch ≥ 100; the build number would not sort after earlier releases" >&2 + exit 1 +fi + +echo $(( MAJOR * 10000 + MINOR * 100 + PATCH )) diff --git a/scripts/make-appcast.sh b/scripts/make-appcast.sh index 279c8de..2bac45e 100755 --- a/scripts/make-appcast.sh +++ b/scripts/make-appcast.sh @@ -1,6 +1,19 @@ #!/bin/bash -# Generates the update feed (appcast.json) the in-app updater reads. +# Generates both update feeds for a release: +# dist/appcast.xml the Sparkle appcast the app reads (SUFeedURL) +# dist/appcast.json the feed copies up to 0.18.5 read with the app's old updater; they +# can only reach a Sparkle build through it, so it keeps being published +# # Usage: ./scripts/make-appcast.sh [notes] +# notes: the release notes, Markdown or plain text (the CHANGELOG section, via +# scripts/release-notes.sh). Default "eWiz ". +# +# Environment, from `licensetool sign-update` (all optional; the feeds carry what's set): +# UPDATE_SHA256 the DMG's digest → appcast.json "sha256" +# UPDATE_SIGNATURE signature over the digest's hex → appcast.json "signature" +# UPDATE_ED_SIGNATURE Sparkle's signature over the bytes → appcast.xml sparkle:edSignature +# UPDATE_LENGTH the DMG's size in bytes → appcast.xml length +# (read from dist/eWiz-.dmg when unset and present) set -euo pipefail VERSION="${1:?usage: make-appcast.sh [notes]}" @@ -8,19 +21,27 @@ DMG_URL="${2:?usage: make-appcast.sh [notes]}" NOTES="${3:-eWiz $VERSION}" REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -mkdir -p "$REPO_DIR/dist" -OUT="$REPO_DIR/dist/appcast.json" +DIST="$REPO_DIR/dist" +mkdir -p "$DIST" +BUILD_NUMBER="$("$REPO_DIR/scripts/build-number.sh" "$VERSION")" -# Escape double quotes/newlines in notes for JSON. -ESCAPED_NOTES=$(printf '%s' "$NOTES" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))') +LENGTH="${UPDATE_LENGTH:-}" +if [[ -z "$LENGTH" && -f "$DIST/eWiz-$VERSION.dmg" ]]; then + LENGTH=$(stat -f %z "$DIST/eWiz-$VERSION.dmg") +fi + +if [[ -z "${UPDATE_ED_SIGNATURE:-}" ]]; then + echo "warning: UPDATE_ED_SIGNATURE is unset — the app will refuse this appcast.xml (SUPublicEDKey demands a signature)" >&2 +fi + +# ---- appcast.json (legacy) ------------------------------------------------------------- -# The DMG's digest and the release key's signature over it, when the release was signed -# (UPDATE_SHA256 / UPDATE_SIGNATURE). They let an ad-hoc build install the update itself. +ESCAPED_NOTES=$(printf '%s' "$NOTES" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))') SIGNED="" if [[ -n "${UPDATE_SHA256:-}" && -n "${UPDATE_SIGNATURE:-}" ]]; then - SIGNED=$(printf ',\n "sha256": "%s",\n "signature": "%s"' "$UPDATE_SHA256" "$UPDATE_SIGNATURE") + SIGNED=$(printf ',\n "sha256": "%s",\n "signature": "%s"' "$UPDATE_SHA256" "$UPDATE_SIGNATURE") fi -cat > "$OUT" < "$DIST/appcast.json" < "$OUT" <, everything else is +# an XML attribute or text node, and quoting all of that in shell is how feeds break. +VERSION="$VERSION" DMG_URL="$DMG_URL" NOTES="$NOTES" BUILD_NUMBER="$BUILD_NUMBER" \ +LENGTH="$LENGTH" ED_SIGNATURE="${UPDATE_ED_SIGNATURE:-}" OUT="$DIST/appcast.xml" \ +python3 - <<'PY' +import os, re +from email.utils import formatdate +from html import escape + +def notes_html(text): + """The CHANGELOG section as simple HTML: headings, bullet lists and paragraphs. + Sparkle renders as HTML, so plain newlines would run together.""" + out, items, para = [], [], [] + def flush_items(): + if items: + out.append("
    " + "".join(f"
  • {i}
  • " for i in items) + "
"); items.clear() + def flush_para(): + if para: + out.append("

" + " ".join(para) + "

"); para.clear() + def inline(s): + s = escape(s, quote=False) + s = re.sub(r"`([^`]+)`", r"\1", s) + s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s) + return s + for raw in text.splitlines(): + line = raw.strip() + if not line: + flush_items(); flush_para(); continue + m = re.match(r"^(#{1,6})\s+(.*)$", line) + if m: + flush_items(); flush_para() + level = min(len(m.group(1)) + 1, 6) # "## 0.19.0" → h3, below the item title + out.append(f"{inline(m.group(2))}"); continue + m = re.match(r"^[-*+]\s+(.*)$", line) + if m: + flush_para(); items.append(inline(m.group(1))); continue + if items and raw.startswith((" ", "\t")): + items[-1] += " " + inline(line); continue # a wrapped bullet + flush_items(); para.append(inline(line)) + flush_items(); flush_para() + return "\n".join(out) + +e = os.environ +version, build, url = e["VERSION"], e["BUILD_NUMBER"], e["DMG_URL"] +length = f' length="{escape(e["LENGTH"])}"' if e["LENGTH"] else "" +ed = f' sparkle:edSignature="{escape(e["ED_SIGNATURE"])}"' if e["ED_SIGNATURE"] else "" +notes = notes_html(e["NOTES"]).replace("]]>", "]]]]>") + +xml = f''' + + + eWiz + https://ewiz.app + eWiz updates + en + + eWiz {escape(version)} + {formatdate(usegmt=True)} + {escape(build)} + {escape(version)} + 14.0 + + + + + +''' +with open(e["OUT"], "w", encoding="utf-8") as f: + f.write(xml) +PY + +echo "wrote $DIST/appcast.json:" +cat "$DIST/appcast.json" +echo "wrote $DIST/appcast.xml:" +cat "$DIST/appcast.xml" diff --git a/scripts/package-app.sh b/scripts/package-app.sh index 25865e3..dd60fd9 100755 --- a/scripts/package-app.sh +++ b/scripts/package-app.sh @@ -16,11 +16,21 @@ cd "$REPO_DIR" # Optimize for size (-Osize) and let the linker drop unreachable code # (-dead_strip). Smaller text pages → smaller footprint, no behavior change. BUILD_FLAGS=(-c release -Xswiftc -Osize -Xlinker -dead_strip) -swift build "${BUILD_FLAGS[@]}" --product eWiz +# Sparkle.framework's install name is @rpath/Sparkle.framework/…, and SwiftPM links it +# without embedding it anywhere: the app finds it in Contents/Frameworks (copied below) +# only because of this rpath. +swift build "${BUILD_FLAGS[@]}" -Xlinker -rpath -Xlinker @executable_path/../Frameworks --product eWiz swift build "${BUILD_FLAGS[@]}" --product ewiz-helper swift build "${BUILD_FLAGS[@]}" --product ewiz-mcp BIN_DIR="$REPO_DIR/.build/release" +# Sparkle ships as a binary xcframework inside the package artifact; take the macOS slice. +SPARKLE_SRC="$(find "$REPO_DIR/.build/artifacts" -type d -name Sparkle.framework -path '*macos*' | head -1)" +if [[ -z "$SPARKLE_SRC" ]]; then + echo "error: Sparkle.framework not found under .build/artifacts — did the package resolve?" >&2 + exit 1 +fi + echo "==> Assembling $APP" rm -rf "$APP_DIR" mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" @@ -89,6 +99,14 @@ chmod 755 "$CONTENTS/MacOS/ewiz-helper" "$CONTENTS/MacOS/ewiz-mcp" \ "$CONTENTS/Resources/install-helper-bundled.sh" \ "$CONTENTS/Resources/uninstall-helper.sh" +# The updater. ditto keeps the framework's Versions/Current symlinks. Headers and module +# maps are build-time only, so they stay out of the bundle (Xcode drops them on embed too). +mkdir -p "$CONTENTS/Frameworks" +SPARKLE="$CONTENTS/Frameworks/Sparkle.framework" +ditto "$SPARKLE_SRC" "$SPARKLE" +rm -rf "$SPARKLE/Versions/B/Headers" "$SPARKLE/Versions/B/PrivateHeaders" "$SPARKLE/Versions/B/Modules" \ + "$SPARKLE/Headers" "$SPARKLE/PrivateHeaders" "$SPARKLE/Modules" + # Strip local/debug symbols before signing (must precede codesign or it would # invalidate the signature). -x keeps external symbols, so nothing breaks. strip -x "$CONTENTS/MacOS/eWiz" @@ -96,6 +114,22 @@ strip -x "$CONTENTS/MacOS/ewiz-helper" strip -x "$CONTENTS/MacOS/ewiz-mcp" # Info.plist — LSUIElement makes it a menu-bar-only (agent) app. +# +# CFBundleVersion is the build number Sparkle compares (see scripts/build-number.sh), and +# the SU* keys configure it: the feed, the release key every download has to verify +# against (UpdateSignature.publicKeyBase64 — the same key signs appcast.json for the old +# updater), a check on launch and every day without asking first, and verification of the +# image before it's even opened. The app is ad-hoc signed, so SUPublicEDKey is the only +# proof Sparkle has; it accepts that in place of a Developer ID match. +BUILD_NUMBER="$("$REPO_DIR/scripts/build-number.sh" "$VERSION")" +SPARKLE_FEED="https://raw.githubusercontent.com/stroke-app/ewiz-releases/main/appcast.xml" +# Read from the source so the key the app trusts and the key licensetool checks its +# signatures against are one constant. +SPARKLE_PUBLIC_KEY="$(sed -n 's/.*publicKeyBase64 = "\([A-Za-z0-9+\/=]*\)".*/\1/p' "$REPO_DIR/Sources/EWizKit/UpdateSignature.swift")" +if [[ ${#SPARKLE_PUBLIC_KEY} -ne 44 ]]; then + echo "error: could not read UpdateSignature.publicKeyBase64 (got '$SPARKLE_PUBLIC_KEY')" >&2 + exit 1 +fi cat > "$CONTENTS/Info.plist" < "$CONTENTS/Info.plist" <CFBundleIconFile AppIcon$ICON_NAME_PLIST CFBundlePackageType APPL CFBundleShortVersionString$VERSION - CFBundleVersion $VERSION + CFBundleVersion $BUILD_NUMBER LSMinimumSystemVersion 14.0 LSUIElement NSHumanReadableCopyright eWiz + + SUFeedURL $SPARKLE_FEED + SUPublicEDKey $SPARKLE_PUBLIC_KEY + SUEnableAutomaticChecks + SUScheduledCheckInterval 86400 + SUVerifyUpdateBeforeExtraction NSBluetoothAlwaysUsageDescription @@ -141,11 +181,23 @@ fi chmod 755 "$CONTENTS/MacOS/eWiz" "$CONTENTS/MacOS/ewiz-helper" "$CONTENTS/MacOS/ewiz-mcp" chmod -R go+rX "$APP_DIR" -# Sign nested executables first, then the app bundle (no deprecated --deep). +# Sign innermost first, then outward, and never --deep (deprecated, and it signs in the +# wrong order). Sparkle's pieces arrive ad-hoc signed by its own build; they are re-signed +# here so the whole bundle carries one identity, in the order Sparkle's documentation +# gives (sparkle-project.org/documentation/sandboxing): the XPC services, Autoupdate and +# Updater.app inside the framework, then the framework, then our executables, then the app. +# Downloader.xpc keeps its entitlements (network client), which --force would otherwise drop. +codesign "${SIGN_FLAGS[@]}" "$SPARKLE/Versions/B/XPCServices/Installer.xpc" +codesign "${SIGN_FLAGS[@]}" --preserve-metadata=entitlements "$SPARKLE/Versions/B/XPCServices/Downloader.xpc" +codesign "${SIGN_FLAGS[@]}" "$SPARKLE/Versions/B/Autoupdate" +codesign "${SIGN_FLAGS[@]}" "$SPARKLE/Versions/B/Updater.app" +codesign "${SIGN_FLAGS[@]}" "$SPARKLE" codesign "${SIGN_FLAGS[@]}" "$CONTENTS/MacOS/ewiz-helper" codesign "${SIGN_FLAGS[@]}" "$CONTENTS/MacOS/ewiz-mcp" codesign "${SIGN_FLAGS[@]}" "$APP_DIR" -codesign --verify --strict --verbose=2 "$APP_DIR" || echo "warning: verify failed" +# --deep here only *checks* nested code; Sparkle's installer does the same before it +# will swap this bundle in, so a mis-signed piece should fail the build, not the update. +codesign --verify --deep --strict --verbose=2 "$APP_DIR" || echo "warning: verify failed" echo "==> Creating zip" cd "$DIST"