Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/sparkle-updates.md
Original file line number Diff line number Diff line change
@@ -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.
80 changes: 49 additions & 31 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,21 +74,23 @@ 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:
UPDATE_SIGNING_PRIVATE_KEY: ${{ secrets.UPDATE_SIGNING_PRIVATE_KEY }}
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
Expand All @@ -97,27 +99,31 @@ 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: |
./scripts/release-notes.sh "${{ steps.ver.outputs.version }}" > dist/release-notes.md
[ -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
Expand All @@ -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 }}
Expand All @@ -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.
Expand Down
56 changes: 39 additions & 17 deletions .github/workflows/update-feed.yml
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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 }}
Expand All @@ -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
48 changes: 25 additions & 23 deletions DISTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
15 changes: 15 additions & 0 deletions Package.resolved

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

10 changes: 9 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions Sources/EWizKit/EWizLinks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")!
}
Loading
Loading