diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 928708a..a92fcb2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,6 +41,19 @@ jobs:
exit 1
fi
+ - name: Sparkle embedding verifier
+ run: |
+ ./scripts/build-app.sh --version 0.0.0-ci
+ ./scripts/verify-sparkle-embedding.sh dist/WinMice.app
+
+ - name: Packaged zip verifier
+ run: |
+ ./scripts/package-release.sh 0.0.0-ci
+ ./scripts/verify-packaged-zip.sh dist/WinMice-0.0.0-ci.zip
+
+ - name: Appcast generator test
+ run: ./scripts/test-generate-appcast.sh
+
- name: Pages home verifier
run: ./scripts/verify-pages-home.sh
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9a6a5c4..32b8892 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -50,7 +50,19 @@ jobs:
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "publish=$PUBLISH" >> "$GITHUB_OUTPUT"
- - name: Require Apple signing secrets
+ - name: Require a changelog for this version
+ env:
+ VERSION: ${{ steps.ver.outputs.version }}
+ run: |
+ # Both the release notes and the Sparkle appcast are generated from this
+ # file. Checking it here fails in seconds instead of after notarization.
+ if [ ! -f "docs/changelog/${VERSION}.md" ]; then
+ echo "Missing docs/changelog/${VERSION}.md" >&2
+ echo "Write the changelog first; the release notes and the appcast both come from it." >&2
+ exit 1
+ fi
+
+ - name: Require signing secrets
env:
APPLE_DEVELOPER_ID_P12_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_P12_BASE64 }}
APPLE_DEVELOPER_ID_P12_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_P12_PASSWORD }}
@@ -58,10 +70,12 @@ jobs:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
+ SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
missing=0
for v in APPLE_DEVELOPER_ID_P12_BASE64 APPLE_DEVELOPER_ID_P12_PASSWORD \
- APPLE_TEAM_ID APPLE_API_KEY_ID APPLE_API_ISSUER_ID APPLE_API_KEY_P8; do
+ APPLE_TEAM_ID APPLE_API_KEY_ID APPLE_API_ISSUER_ID APPLE_API_KEY_P8 \
+ SPARKLE_ED_PRIVATE_KEY; do
eval "val=\${$v}"
if [ -z "$val" ]; then
echo "Missing secret: $v" >&2
@@ -95,6 +109,9 @@ jobs:
VERSION: ${{ steps.ver.outputs.version }}
run: ./scripts/verify-bundle-metadata.sh dist/WinMice.app "$VERSION"
+ - name: Verify Sparkle embedding
+ run: ./scripts/verify-sparkle-embedding.sh dist/WinMice.app
+
- name: Notarize app
env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
@@ -107,9 +124,17 @@ jobs:
run: ./scripts/verify-notarized-app.sh dist/WinMice.app
- name: Package zip
+ id: pkg
env:
VERSION: ${{ steps.ver.outputs.version }}
- run: ./scripts/package-release.sh "$VERSION"
+ run: |
+ ./scripts/package-release.sh "$VERSION"
+ echo "zip=dist/WinMice-${VERSION}.zip" >> "$GITHUB_OUTPUT"
+
+ - name: Verify packaged zip
+ env:
+ ZIP: ${{ steps.pkg.outputs.zip }}
+ run: ./scripts/verify-packaged-zip.sh "$ZIP"
- name: Install create-dmg
run: |
@@ -159,6 +184,19 @@ jobs:
echo "zip=$ZIP" >> "$GITHUB_OUTPUT"
echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
+ - name: Generate appcast
+ env:
+ VERSION: ${{ steps.ver.outputs.version }}
+ ZIP: ${{ steps.sum.outputs.zip }}
+ SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
+ run: |
+ KEY_FILE="${RUNNER_TEMP}/sparkle-ed-private-key"
+ umask 077
+ printf '%s' "$SPARKLE_ED_PRIVATE_KEY" > "$KEY_FILE"
+ SPARKLE_ED_KEY_FILE="$KEY_FILE" \
+ ./scripts/generate-appcast.sh "$VERSION" "$ZIP" "docs/changelog/${VERSION}.md"
+ rm -f "$KEY_FILE"
+
- name: Upload workflow artifacts (dry run / debugging)
if: steps.ver.outputs.publish != 'true'
uses: actions/upload-artifact@v4
@@ -167,15 +205,14 @@ jobs:
path: |
${{ steps.sum.outputs.zip }}
${{ steps.sum.outputs.dmg }}
+ dist/appcast.xml
if-no-files-found: error
- name: Render release notes
if: steps.ver.outputs.publish == 'true'
env:
VERSION: ${{ steps.ver.outputs.version }}
- run: |
- sed "s/{{VERSION}}/${VERSION}/g" \
- docs/release-notes-template.md > /tmp/release-notes.md
+ run: ./scripts/render-release-notes.sh "$VERSION" > /tmp/release-notes.md
- name: Publish GitHub Release
if: steps.ver.outputs.publish == 'true'
@@ -187,9 +224,9 @@ jobs:
DMG: ${{ steps.sum.outputs.dmg }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
- gh release upload "$TAG" "$ZIP" "$DMG" --clobber
+ gh release upload "$TAG" "$ZIP" "$DMG" dist/appcast.xml --clobber
else
- gh release create "$TAG" "$ZIP" "$DMG" \
+ gh release create "$TAG" "$ZIP" "$DMG" dist/appcast.xml \
--title "WinMice $VERSION" \
--notes-file /tmp/release-notes.md
fi
@@ -235,6 +272,7 @@ jobs:
security delete-keychain "$KEYCHAIN" || true
rm -f "${RUNNER_TEMP}/winmice-keychain-path" \
"${RUNNER_TEMP}/winmice-signing.p12" \
+ "${RUNNER_TEMP}/sparkle-ed-private-key" \
"${RUNNER_TEMP}"/AuthKey_*.p8 \
"${RUNNER_TEMP}"/*.notarize.zip \
"${RUNNER_TEMP}"/*.notary.json || true
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..fcd1d78
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,15 @@
+{
+ "originHash" : "d9d48e2b7900c0ea9e3f2e4d9b0d67bc554133d29c70bda379bb873e90986b87",
+ "pins" : [
+ {
+ "identity" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle",
+ "state" : {
+ "revision" : "ac2def288cbff5cfc7df3ffef6abdf45b72bcb0a",
+ "version" : "2.9.6"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/Package.swift b/Package.swift
index 244d8f1..db5003d 100644
--- a/Package.swift
+++ b/Package.swift
@@ -10,6 +10,9 @@ let package = Package(
products: [
.executable(name: "WinMice", targets: ["WinMice"])
],
+ dependencies: [
+ .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0")
+ ],
targets: [
.target(
name: "SwipeGesturePoster",
@@ -37,7 +40,12 @@ let package = Package(
),
.executableTarget(
name: "WinMice",
- dependencies: ["SwipeGesturePoster", "ScrollEngine", "SideButtons"],
+ dependencies: [
+ "SwipeGesturePoster",
+ "ScrollEngine",
+ "SideButtons",
+ .product(name: "Sparkle", package: "Sparkle")
+ ],
path: "Sources/WinMice",
swiftSettings: [
.unsafeFlags(["-warnings-as-errors"])
@@ -46,7 +54,14 @@ let package = Package(
.linkedFramework("AppKit"),
.linkedFramework("SwiftUI"),
.linkedFramework("ApplicationServices"),
- .linkedFramework("ServiceManagement")
+ .linkedFramework("ServiceManagement"),
+ // SwiftPM has no equivalent of Xcode's "Embed & Sign", and it does not add
+ // a Frameworks rpath to executables. Without this the app links fine and
+ // then dies at launch with a dyld "Library not loaded: @rpath/Sparkle.framework".
+ // NOTE: the combined "-Wl,-rpath,PATH" form is rejected by this toolchain's
+ // swiftc driver ("unknown argument"); -Xlinker pairs produce the identical
+ // LC_RPATH and are accepted. See task-1-report.md for verification.
+ .unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@loader_path/../Frameworks"])
]
),
.testTarget(
diff --git a/README.md b/README.md
index e3d3a20..5ce7beb 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-A tiny native macOS menu-bar utility that brings Windows-style mouse behavior to Mac: vector scrolling on middle-click and configurable back/forward side buttons. Built on Swift and AppKit with no Electron or bundled runtimes. Ships as a ~600 KB app, runs as a single lightweight process, and uses negligible CPU and memory while idle.
+A tiny native macOS menu-bar utility that brings Windows-style mouse behavior to Mac: vector scrolling on middle-click and configurable back/forward side buttons. Built on Swift and AppKit with no Electron and no interpreted runtime. Ships as a ~1.7 MB download, about 4 MB installed — the app's own binary is under 1 MB and the rest is the Sparkle framework that delivers updates — runs as a single lightweight process, and uses negligible CPU and memory while idle.
**[Product site](https://anibalribeiro.cz/Winmice/)** — screenshots and a quick demo loop.
@@ -33,6 +33,20 @@ row under **System Settings → Privacy & Security → Accessibility**, then
re-enable `/Applications/WinMice.app` once. Later notarized updates keep that
Developer ID identity.
+### Updates
+
+WinMice asks, shortly after you first run it, whether it may check for updates.
+If you agree it checks once a day, shows you what changed, and installs the
+update only when you say so. You can change your mind any time in
+**Settings → General → Updates**, or check on demand with **Check for
+Updates…** in the menu bar menu.
+
+Homebrew users can keep using `brew upgrade --cask winmice` instead.
+
+Coming from 1.0.0, this one upgrade has to be done by hand — 1.0.0 has no
+updater to tell it about anything newer. Every release after this one can
+update itself.
+
## Use
Open **Settings…** from the menu bar to configure:
diff --git a/Sources/WinMice/Settings/Panes/GeneralPane.swift b/Sources/WinMice/Settings/Panes/GeneralPane.swift
index fd0924c..a2163e1 100644
--- a/Sources/WinMice/Settings/Panes/GeneralPane.swift
+++ b/Sources/WinMice/Settings/Panes/GeneralPane.swift
@@ -3,6 +3,7 @@ import SwiftUI
struct GeneralPane: View {
@ObservedObject var settings: AppSettings
+ @ObservedObject var updater: UpdateController
@State private var loginItemError: String?
@State private var isConfirmingReset = false
@@ -29,6 +30,13 @@ struct GeneralPane: View {
}
}
+ Section("Updates") {
+ Toggle(isOn: automaticallyChecksForUpdates) {
+ Text("Check for updates automatically")
+ Text("WinMice checks once a day and asks before installing anything.")
+ }
+ }
+
Section {
LabeledContent {
Button("Restore Defaults…") {
@@ -58,7 +66,7 @@ struct GeneralPane: View {
}
Button("Cancel", role: .cancel) {}
} message: {
- Text("Open at login is a system setting and stays as it is.")
+ Text("Open at login and the automatic update check are handled outside WinMice's own settings and stay as they are.")
}
}
@@ -75,4 +83,11 @@ struct GeneralPane: View {
}
)
}
+
+ private var automaticallyChecksForUpdates: Binding {
+ Binding(
+ get: { updater.automaticallyChecksForUpdates },
+ set: { updater.automaticallyChecksForUpdates = $0 }
+ )
+ }
}
diff --git a/Sources/WinMice/Settings/SettingsView.swift b/Sources/WinMice/Settings/SettingsView.swift
index 60736c4..82cbd55 100644
--- a/Sources/WinMice/Settings/SettingsView.swift
+++ b/Sources/WinMice/Settings/SettingsView.swift
@@ -5,6 +5,7 @@ struct SettingsView: View {
@ObservedObject var settings: AppSettings
@ObservedObject var permissions: PermissionsMonitor
@ObservedObject var recorder: ButtonRecorder
+ @ObservedObject var updater: UpdateController
@State private var pane: SettingsPane = .scrolling
@@ -88,7 +89,7 @@ struct SettingsView: View {
case .buttons:
ButtonsPane(settings: settings, recorder: recorder)
case .general:
- GeneralPane(settings: settings)
+ GeneralPane(settings: settings, updater: updater)
case .permissions:
PermissionsPane(permissions: permissions)
case .about:
diff --git a/Sources/WinMice/Settings/SettingsWindowController.swift b/Sources/WinMice/Settings/SettingsWindowController.swift
index a54299b..edb16fa 100644
--- a/Sources/WinMice/Settings/SettingsWindowController.swift
+++ b/Sources/WinMice/Settings/SettingsWindowController.swift
@@ -13,7 +13,12 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private let recorder: ButtonRecorder
- init(settings: AppSettings, permissions: PermissionsMonitor, recorder: ButtonRecorder) {
+ init(
+ settings: AppSettings,
+ permissions: PermissionsMonitor,
+ recorder: ButtonRecorder,
+ updater: UpdateController
+ ) {
self.recorder = recorder
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: Self.defaultContentSize),
@@ -32,7 +37,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
rootView: SettingsView(
settings: settings,
permissions: permissions,
- recorder: recorder
+ recorder: recorder,
+ updater: updater
)
)
window.appearance = NSAppearance(named: .aqua)
diff --git a/Sources/WinMice/UpdateController.swift b/Sources/WinMice/UpdateController.swift
new file mode 100644
index 0000000..5a2911d
--- /dev/null
+++ b/Sources/WinMice/UpdateController.swift
@@ -0,0 +1,47 @@
+import Combine
+import Sparkle
+
+/// Thin wrapper over Sparkle's standard updater.
+///
+/// Sparkle owns all update state: `automaticallyChecksForUpdates` reads and writes straight
+/// through to `SPUUpdater`, which persists it in `UserDefaults` under `SUEnableAutomaticChecks`.
+/// It is deliberately not mirrored into `AppSettings` — Sparkle's own first-launch permission
+/// prompt writes the same value, so a second copy would drift.
+@MainActor
+final class UpdateController: ObservableObject {
+ private let controller: SPUStandardUpdaterController
+ /// Sparkle's own first-launch/second-launch permission prompt writes
+ /// `automaticallyChecksForUpdates` directly on `SPUUpdater`
+ /// (`updatePermissionRequestFinishedWithResponse:`), bypassing the setter below entirely.
+ /// KVO is the only way to notice that write and republish it to SwiftUI — Sparkle documents
+ /// the property as KVO-compliant and main-thread-only, which is what makes the
+ /// `MainActor.assumeIsolated` in the handler below sound.
+ private var automaticChecksObservation: NSKeyValueObservation?
+
+ init() {
+ // startingUpdater: true schedules the first check itself. Because Info.plist omits
+ // SUEnableAutomaticChecks, Sparkle asks the user for permission before checking.
+ controller = SPUStandardUpdaterController(
+ startingUpdater: true,
+ updaterDelegate: nil,
+ userDriverDelegate: nil
+ )
+ automaticChecksObservation = controller.updater.observe(\.automaticallyChecksForUpdates) { [weak self] _, _ in
+ MainActor.assumeIsolated {
+ self?.objectWillChange.send()
+ }
+ }
+ }
+
+ /// User-initiated check. Always reports an outcome, even when already up to date.
+ func checkForUpdates() {
+ controller.updater.checkForUpdates()
+ }
+
+ /// Pass-through. Notification is driven by the KVO observation set up in `init`,
+ /// not from here, so writes made by Sparkle itself are published too.
+ var automaticallyChecksForUpdates: Bool {
+ get { controller.updater.automaticallyChecksForUpdates }
+ set { controller.updater.automaticallyChecksForUpdates = newValue }
+ }
+}
diff --git a/Sources/WinMice/main.swift b/Sources/WinMice/main.swift
index bcf9856..7d27bb5 100644
--- a/Sources/WinMice/main.swift
+++ b/Sources/WinMice/main.swift
@@ -17,6 +17,7 @@ private final class WinMiceApp: NSObject, NSApplicationDelegate {
private let indicator = ScrollIndicatorWindow()
private let navigation = NavigationController()
private let recorder = ButtonRecorder()
+ private let updater = UpdateController()
private var statusItem: NSStatusItem?
private var menu: NSMenu!
@@ -78,7 +79,8 @@ private final class WinMiceApp: NSObject, NSApplicationDelegate {
settingsController = SettingsWindowController(
settings: settings,
permissions: permissions,
- recorder: recorder
+ recorder: recorder,
+ updater: updater
)
configureMainMenu()
@@ -151,6 +153,11 @@ private final class WinMiceApp: NSObject, NSApplicationDelegate {
settingsItem.target = self
menu.addItem(settingsItem)
+ let updateItem = NSMenuItem(title: "Check for Updates…", action: #selector(checkForUpdates), keyEquivalent: "")
+ updateItem.target = self
+ updateItem.image = NSImage(systemSymbolName: "arrow.down.circle", accessibilityDescription: "Check for updates")
+ menu.addItem(updateItem)
+
let supportItem = NSMenuItem(title: "Buy me a coffee…", action: #selector(openSupportLink), keyEquivalent: "")
supportItem.target = self
supportItem.image = NSImage(systemSymbolName: "cup.and.saucer.fill", accessibilityDescription: "Buy me a coffee")
@@ -255,6 +262,10 @@ private final class WinMiceApp: NSObject, NSApplicationDelegate {
NSWorkspace.shared.open(AppInfo.supportURL)
}
+ @objc private func checkForUpdates() {
+ updater.checkForUpdates()
+ }
+
/// Reopening from Finder or Spotlight is the only way back once the icon is hidden.
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
if settings.menuBarIconHidden {
diff --git a/docs/appcast-template.xml b/docs/appcast-template.xml
new file mode 100644
index 0000000..311e746
--- /dev/null
+++ b/docs/appcast-template.xml
@@ -0,0 +1,25 @@
+
+
+
+ WinMice
+ https://github.com/anibalribeiro/WinMice/releases/latest/download/appcast.xml
+ Updates for WinMice
+ en
+
+ WinMice {{VERSION}}
+ https://anibalribeiro.cz/Winmice/
+ {{VERSION}}
+ {{VERSION}}
+ 14.0
+ https://github.com/anibalribeiro/WinMice/releases
+ {{PUBDATE}}
+
+
+
+
+
diff --git a/docs/changelog/1.1.0.md b/docs/changelog/1.1.0.md
new file mode 100644
index 0000000..359db85
--- /dev/null
+++ b/docs/changelog/1.1.0.md
@@ -0,0 +1,3 @@
+- Added **Reverse vertical** and **Reverse horizontal** settings for middle-click autoscroll.
+- Fixed one mouse button being able to drive both back and forward navigation at once.
+- WinMice now checks for updates once a day and can install them for you. This is the last upgrade you have to do by hand.
diff --git a/docs/release-notes-template.md b/docs/release-notes-template.md
index 8252dc5..480bd48 100644
--- a/docs/release-notes-template.md
+++ b/docs/release-notes-template.md
@@ -1,5 +1,9 @@
## WinMice {{VERSION}}
+### Changes
+
+{{CHANGES}}
+
### Install
1. Download `WinMice-{{VERSION}}.dmg` below, open it, and drag **WinMice** to **Applications**.
diff --git a/docs/releasing.md b/docs/releasing.md
index 77084f5..ff8723d 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -105,6 +105,50 @@ gh secret set HOMEBREW_TAP_TOKEN --repo anibalribeiro/WinMice
gh secret list --repo anibalribeiro/WinMice | grep HOMEBREW_TAP_TOKEN
```
+## Prerequisites: Sparkle update signing
+
+### 1. Generate the EdDSA keypair (once)
+
+Sparkle's tools live in the resolved SPM artifacts after a build:
+
+```bash
+swift build -c release
+"$(find .build/artifacts -type f -name generate_keys | head -n 1)"
+```
+
+This saves the private key in your login Keychain and prints the **public** key.
+Put that public key in `SPARKLE_PUBLIC_ED_KEY` at the top of
+`scripts/build-app.sh` — it is not a secret and ships in every copy of the app.
+
+Export the private key and store it in your password manager next to the `.p12`
+password:
+
+```bash
+"$(find .build/artifacts -type f -name generate_keys | head -n 1)" -x sparkle-private-key.txt
+```
+
+Losing both the Keychain copy and this export would normally end your ability
+to ship updates, because `SUPublicEDKey` is baked into every installed copy.
+Sparkle can fall back to Developer ID code-signing verification, but do not
+rely on that.
+
+### 2. Store the private key on the WinMice repo
+
+```bash
+gh secret set SPARKLE_ED_PRIVATE_KEY --repo anibalribeiro/WinMice < sparkle-private-key.txt
+rm sparkle-private-key.txt
+```
+
+### 3. Mark the cask as self-updating (once)
+
+In the `anibalribeiro/homebrew-winmice` repo, add `auto_updates true` to
+`Casks/winmice.rb`, below the `app "WinMice.app"` line. Without it, `brew`
+keeps believing the user is on the version it installed while Sparkle has moved
+them forward, and `brew upgrade` will try to reinstall over a newer app.
+
+`scripts/update-homebrew-cask.sh` only rewrites `version` and `sha256`, so this
+is a one-time manual commit rather than something the pipeline does.
+
## Dry-run before the first real tag
After secrets are set, prove the pipeline **without** publishing a GitHub
@@ -112,7 +156,9 @@ Release or bumping Homebrew:
1. GitHub → **Actions** → **Release** → **Run workflow**
2. Leave **publish** unchecked (default)
-3. Optionally set a dry-run version string
+3. Set the version to one that has a `docs/changelog/.md`. This is not
+ optional any more: the appcast is generated from that file, so the default
+ `0.0.0-notarize-dry-run` has no changelog and the run stops immediately.
4. Confirm **Notarize app** / **Notarize DMG** / verify steps succeed
5. Download the workflow artifacts and smoke-test Gatekeeper + Accessibility
@@ -120,31 +166,42 @@ Only then cut a real `vX.Y.Z` tag.
## Release checklist
-1. Ensure Apple + Homebrew secrets above are set, and a dry-run has passed once.
+1. Ensure the Apple, Homebrew, and Sparkle prerequisites above are set, and a
+ dry-run has passed once.
+
+2. Write `docs/changelog/.md` as a plain bullet list. The release will
+ not build without it — it feeds both the GitHub release notes and the
+ in-app update dialog.
-2. Ensure `main` is green locally:
+3. Ensure `main` is green locally:
```bash
+ swift test
./scripts/test-build-signing-mode.sh
- ./scripts/build-app.sh --version 1.0.1 && ./scripts/verify-bundle-metadata.sh dist/WinMice.app 1.0.1
+ ./scripts/test-generate-appcast.sh
+ ./scripts/build-app.sh --version 1.1.0
+ ./scripts/verify-bundle-metadata.sh dist/WinMice.app 1.1.0
+ ./scripts/verify-sparkle-embedding.sh dist/WinMice.app
+ ./scripts/render-release-notes.sh 1.1.0
```
- When `create-dmg` is installed (`brew install create-dmg`), also verify the disk image:
+ When `create-dmg` is installed (`brew install create-dmg`), also verify the
+ disk image:
```bash
- ./scripts/package-dmg.sh 1.0.1
+ ./scripts/package-dmg.sh 1.1.0
```
-3. Commit/push any pending release notes or docs on `main`.
+4. Commit/push any pending release notes or docs on `main`.
-4. Tag and push:
+5. Tag and push:
```bash
- git tag v1.0.1
- git push origin v1.0.1
+ git tag v1.1.0
+ git push origin v1.1.0
```
-5. Watch **Actions** → **Release**:
+6. Watch **Actions** → **Release**:
```bash
gh run watch --repo anibalribeiro/WinMice
@@ -153,13 +210,26 @@ Only then cut a real `vX.Y.Z` tag.
Confirm steps **Notarize app**, **Verify notarized app**, **Notarize DMG**,
and **Verify notarized DMG** succeed.
-6. Confirm the GitHub Release includes both `WinMice-1.0.1.dmg` and `WinMice-1.0.1.zip`:
+7. Confirm the GitHub Release includes both `WinMice-1.1.0.dmg` and `WinMice-1.1.0.zip`:
```bash
- gh release view v1.0.1 --repo anibalribeiro/WinMice
+ gh release view v1.1.0 --repo anibalribeiro/WinMice
```
-7. Confirm the tap cask was bumped; test install. The cask's `sha256` is the
+- Confirm the release carries `appcast.xml` and that the feed URL resolves:
+
+ ```bash
+ curl -sL https://github.com/anibalribeiro/WinMice/releases/latest/download/appcast.xml | xmllint --noout -
+ ```
+
+- Confirm an older installed copy sees the update: with the previous version in
+ `/Applications`, choose **Check for Updates…** and confirm the dialog offers
+ the new version with the changelog shown. Let it install, then confirm
+ **without touching System Settings** that autoscroll and side buttons still
+ work — the Developer ID identity is stable across releases, so the
+ Accessibility grant should survive.
+
+8. Confirm the tap cask was bumped; test install. The cask's `sha256` is the
**DMG's** checksum (not the zip's) — that's what the workflow's Checksum
step computes and what `brew` verifies:
@@ -171,7 +241,7 @@ Only then cut a real `vX.Y.Z` tag.
brew install --cask winmice
```
-8. Smoke-test Gatekeeper: open `/Applications/WinMice.app` **without** Open Anyway. Grant **Accessibility** when prompted.
+9. Smoke-test Gatekeeper: open `/Applications/WinMice.app` **without** Open Anyway. Grant **Accessibility** when prompted.
**Upgrading from ad-hoc (unsigned) releases:** the first Developer ID build
is a new code identity. Remove every old WinMice row under
diff --git a/scripts/build-app.sh b/scripts/build-app.sh
index 430ae54..5056a6e 100755
--- a/scripts/build-app.sh
+++ b/scripts/build-app.sh
@@ -18,21 +18,41 @@ case "$VERSION" in
"") echo "Version must be non-empty" >&2; exit 1 ;;
esac
+# EdDSA public key Sparkle uses to verify downloaded updates. Not a secret: it
+# ships inside every copy of the app. Generated once with Sparkle's
+# generate_keys; the matching private key is the SPARKLE_ED_PRIVATE_KEY repo
+# secret. verify-bundle-metadata.sh refuses to pass while this is the placeholder.
+SPARKLE_PUBLIC_ED_KEY="Tmdsz0zEHetxSYorQPZoQVFMEXypDhUl4rKOtf48ZBM="
+
swift build -c release
APP_DIR="dist/WinMice.app"
CONTENTS="$APP_DIR/Contents"
MACOS="$CONTENTS/MacOS"
RESOURCES="$CONTENTS/Resources"
+FRAMEWORKS="$CONTENTS/Frameworks"
ICONSET="dist/WinMice.iconset"
rm -rf "$APP_DIR"
-mkdir -p "$MACOS" "$RESOURCES"
+mkdir -p "$MACOS" "$RESOURCES" "$FRAMEWORKS"
cp ".build/release/WinMice" "$MACOS/WinMice"
swift scripts/make-icons.swift "$ICONSET"
iconutil -c icns "$ICONSET" -o "$RESOURCES/WinMice.icns"
+# SwiftPM resolves Sparkle as a binary xcframework but never copies it into the
+# bundle, so stage it by hand. The path embeds both the Sparkle version and the
+# slice name, so discover it rather than hardcoding it.
+SPARKLE_FRAMEWORK=$(find .build/artifacts -type d -name 'Sparkle.framework' -path '*macos*' | head -n 1)
+[ -n "$SPARKLE_FRAMEWORK" ] || {
+ echo "Sparkle.framework not found under .build/artifacts — run 'swift build -c release' first" >&2
+ exit 1
+}
+# -R (not -r) preserves the framework's Versions/Current symlinks; flattening them
+# invalidates the signature.
+rm -rf "$FRAMEWORKS/Sparkle.framework"
+cp -R "$SPARKLE_FRAMEWORK" "$FRAMEWORKS/Sparkle.framework"
+
cat > "$CONTENTS/Info.plist" <
@@ -63,12 +83,35 @@ cat > "$CONTENTS/Info.plist" <NSAccessibilityUsageDescriptionWinMice uses accessibility to bring the window under the cursor forward, post native scroll events, and handle back/forward navigation on your behalf.
+ SUFeedURL
+ https://github.com/anibalribeiro/WinMice/releases/latest/download/appcast.xml
+ SUPublicEDKey
+ ${SPARKLE_PUBLIC_ED_KEY}
+ SUScheduledCheckInterval
+ 86400
PLIST
ENTITLEMENTS="$(CDPATH= cd -- "$(dirname "$0")" && pwd)/WinMice.entitlements"
if [ -n "${CODESIGN_IDENTITY:-}" ]; then
+ # Sparkle's helpers each carry their own signature, and --deep is not used here,
+ # so they must be signed explicitly, inside-out, before the app bundle seals
+ # their hashes. They deliberately do NOT get WinMice's entitlements: the
+ # accessibility grant belongs to the app alone.
+ SPARKLE_VERSIONED="$FRAMEWORKS/Sparkle.framework/Versions/B"
+ for nested in \
+ "$SPARKLE_VERSIONED"/XPCServices/*.xpc \
+ "$SPARKLE_VERSIONED/Updater.app" \
+ "$SPARKLE_VERSIONED/Autoupdate" \
+ "$FRAMEWORKS/Sparkle.framework"
+ do
+ [ -e "$nested" ] || continue
+ codesign --force --options runtime --timestamp \
+ --sign "$CODESIGN_IDENTITY" \
+ "$nested"
+ done
+
# --timestamp requests a secure timestamp explicitly; notarization requires
# one, and codesign's unspecified default may skip it on some signatures.
codesign --force --options runtime --timestamp \
diff --git a/scripts/generate-appcast.sh b/scripts/generate-appcast.sh
new file mode 100755
index 0000000..c0fd06c
--- /dev/null
+++ b/scripts/generate-appcast.sh
@@ -0,0 +1,71 @@
+#!/bin/sh
+set -eu
+
+VERSION="${1:?usage: generate-appcast.sh }"
+ZIP="${2:?usage: generate-appcast.sh }"
+CHANGELOG="${3:?usage: generate-appcast.sh }"
+KEY_FILE="${SPARKLE_ED_KEY_FILE:?SPARKLE_ED_KEY_FILE must point at the exported EdDSA private key}"
+TEMPLATE="docs/appcast-template.xml"
+OUT="dist/appcast.xml"
+
+[ -f "$TEMPLATE" ] || { echo "missing $TEMPLATE" >&2; exit 1; }
+[ -f "$ZIP" ] || { echo "missing zip: $ZIP" >&2; exit 1; }
+[ -f "$CHANGELOG" ] || { echo "missing changelog: $CHANGELOG" >&2; exit 1; }
+[ -f "$KEY_FILE" ] || { echo "missing key file: $KEY_FILE" >&2; exit 1; }
+
+# Sparkle ships sign_update inside the SPM artifact tree that swift build already
+# resolved, so there is nothing extra to download.
+SIGN_UPDATE="${SPARKLE_SIGN_UPDATE:-}"
+if [ -z "$SIGN_UPDATE" ]; then
+ # Sparkle ships two sign_update binaries: the EdDSA one in bin/, and a
+ # deprecated DSA script in bin/old_dsa_scripts/. Exclude the latter so the
+ # right tool is chosen by name rather than by find's traversal order, and
+ # refuse to guess if the layout ever changes.
+ MATCHES=$(find .build/artifacts -type f -name sign_update -not -path '*old_dsa_scripts*')
+ COUNT=$(printf '%s\n' "$MATCHES" | grep -c . || true)
+ if [ "$COUNT" -ne 1 ]; then
+ echo "expected exactly one sign_update under .build/artifacts, found $COUNT" >&2
+ printf '%s\n' "$MATCHES" >&2
+ echo "run 'swift build -c release' first, or set SPARKLE_SIGN_UPDATE" >&2
+ exit 1
+ fi
+ SIGN_UPDATE="$MATCHES"
+fi
+
+# The key must be passed as a file, not inline: sign_update defaults to the login
+# Keychain, which does not exist on a CI runner, and an inline key would show up
+# in the process list.
+FRAGMENT=$("$SIGN_UPDATE" -f "$KEY_FILE" "$ZIP")
+ED_SIGNATURE=$(printf '%s' "$FRAGMENT" | sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p')
+LENGTH=$(printf '%s' "$FRAGMENT" | sed -n 's/.*length="\([0-9]*\)".*/\1/p')
+[ -n "$ED_SIGNATURE" ] || { echo "sign_update produced no signature: $FRAGMENT" >&2; exit 1; }
+[ -n "$LENGTH" ] || { echo "sign_update produced no length: $FRAGMENT" >&2; exit 1; }
+
+PUBDATE=$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')
+
+mkdir -p dist
+awk -v version="$VERSION" \
+ -v sig="$ED_SIGNATURE" \
+ -v len="$LENGTH" \
+ -v pubdate="$PUBDATE" \
+ -v changelog="$CHANGELOG" '
+ $0 ~ /^[[:space:]]*\{\{CHANGES\}\}[[:space:]]*$/ {
+ while ((getline line < changelog) > 0) print line
+ close(changelog)
+ next
+ }
+ {
+ gsub(/\{\{VERSION\}\}/, version)
+ gsub(/\{\{ED_SIGNATURE\}\}/, sig)
+ gsub(/\{\{LENGTH\}\}/, len)
+ gsub(/\{\{PUBDATE\}\}/, pubdate)
+ print
+ }
+' "$TEMPLATE" > "$OUT"
+
+if grep -q '{{' "$OUT"; then
+ echo "unsubstituted placeholder left in $OUT" >&2
+ exit 1
+fi
+
+echo "Wrote $OUT"
diff --git a/scripts/package-release.sh b/scripts/package-release.sh
index 4156c42..fcd3d49 100755
--- a/scripts/package-release.sh
+++ b/scripts/package-release.sh
@@ -8,7 +8,8 @@ OUT="dist/WinMice-${VERSION}.zip"
[ -d "$APP_DIR" ] || { echo "Missing $APP_DIR — run build-app.sh first" >&2; exit 1; }
rm -f "$OUT"
-# -X strips extended attrs that can make checksums flaky across machines
-( cd dist && zip -r -X "$(basename "$OUT")" WinMice.app )
+# Must preserve Sparkle.framework's symlinks verbatim, or the extracted app's
+# signature no longer validates. ditto does; plain zip -r -X does not.
+( cd dist && ditto -c -k --keepParent WinMice.app "$(basename "$OUT")" )
echo "Wrote $OUT"
diff --git a/scripts/render-release-notes.sh b/scripts/render-release-notes.sh
new file mode 100755
index 0000000..7adedf9
--- /dev/null
+++ b/scripts/render-release-notes.sh
@@ -0,0 +1,22 @@
+#!/bin/sh
+set -eu
+
+VERSION="${1:?usage: render-release-notes.sh }"
+TEMPLATE="docs/release-notes-template.md"
+CHANGELOG="docs/changelog/${VERSION}.md"
+
+[ -f "$TEMPLATE" ] || { echo "missing $TEMPLATE" >&2; exit 1; }
+# Failing here is deliberate: a release should not be publishable without notes.
+[ -f "$CHANGELOG" ] || { echo "missing $CHANGELOG — write the changelog before tagging" >&2; exit 1; }
+
+awk -v version="$VERSION" -v changelog="$CHANGELOG" '
+ $0 == "{{CHANGES}}" {
+ while ((getline line < changelog) > 0) print line
+ close(changelog)
+ next
+ }
+ {
+ gsub(/\{\{VERSION\}\}/, version)
+ print
+ }
+' "$TEMPLATE"
diff --git a/scripts/test-build-signing-mode.sh b/scripts/test-build-signing-mode.sh
index 8cdfa4f..6451f22 100755
--- a/scripts/test-build-signing-mode.sh
+++ b/scripts/test-build-signing-mode.sh
@@ -49,6 +49,7 @@ STUB_DIR="$(mktemp -d)"
trap 'rm -rf "$STUB_DIR"' EXIT HUP INT TERM
cat > "$STUB_DIR/codesign" <> "$RECORD_FILE"
printf '%s\n' "\$@" >> "$RECORD_FILE"
exit 0
STUB
@@ -70,6 +71,30 @@ if ! assert_codesign_arg_present "$RECORD_FILE" --timestamp; then
exit 1
fi
+# Sparkle's nested binaries must be signed separately from the app bundle, and
+# only the app bundle may carry entitlements.
+# `|| true` on every grep -c: a zero count makes grep exit 1, which under
+# set -eu would kill the script before the explanatory message below.
+INVOCATIONS=$(grep -c '@@INVOCATION@@' "$RECORD_FILE" || true)
+if [ "$INVOCATIONS" -lt 2 ]; then
+ echo "expected more than one codesign invocation (nested Sparkle signing missing), got $INVOCATIONS" >&2
+ exit 1
+fi
+ENTITLEMENT_USES=$(grep -c -- '^--entitlements$' "$RECORD_FILE" || true)
+if [ "$ENTITLEMENT_USES" -ne 1 ]; then
+ echo "expected exactly one --entitlements use (the app bundle), got $ENTITLEMENT_USES" >&2
+ exit 1
+fi
+if ! grep -q 'Sparkle.framework' "$RECORD_FILE"; then
+ echo "expected Sparkle.framework to be signed explicitly" >&2
+ exit 1
+fi
+# The app bundle must be signed last, so it seals the framework's hashes.
+if [ "$(grep -v '^@@INVOCATION@@$' "$RECORD_FILE" | tail -n 1)" != "dist/WinMice.app" ]; then
+ echo "expected dist/WinMice.app to be the last thing signed" >&2
+ exit 1
+fi
+
# Release path without a real identity must fail clearly (not silently ad-hoc)
export CODESIGN_IDENTITY="Developer ID Application: Not A Real Identity"
export PATH="$ORIG_PATH"
diff --git a/scripts/test-generate-appcast.sh b/scripts/test-generate-appcast.sh
new file mode 100755
index 0000000..fcf162e
--- /dev/null
+++ b/scripts/test-generate-appcast.sh
@@ -0,0 +1,130 @@
+#!/bin/sh
+set -eu
+
+ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
+TMP=$(mktemp -d)
+trap 'rm -rf "$TMP"' EXIT
+
+mkdir -p "$TMP/project/docs" "$TMP/project/dist" "$TMP/bin"
+cp "$ROOT/docs/appcast-template.xml" "$TMP/project/docs/appcast-template.xml"
+printf 'zip contents\n' > "$TMP/project/dist/WinMice-9.9.9.zip"
+printf -- '- Added a thing.\n- Fixed another thing.\n' > "$TMP/project/docs/changelog-9.9.9.md"
+printf 'not-a-real-private-key\n' > "$TMP/key"
+
+cat > "$TMP/bin/sign_update" <<'EOF'
+#!/bin/sh
+printf '%s\n' "$*" > "$TEST_STATE/sign_update_args"
+printf 'sparkle:edSignature="dGVzdHNpZ25hdHVyZQ==" length="4242"\n'
+EOF
+chmod +x "$TMP/bin/sign_update"
+
+export TEST_STATE="$TMP"
+export SPARKLE_SIGN_UPDATE="$TMP/bin/sign_update"
+export SPARKLE_ED_KEY_FILE="$TMP/key"
+
+if ! (
+ cd "$TMP/project"
+ "$ROOT/scripts/generate-appcast.sh" 9.9.9 dist/WinMice-9.9.9.zip docs/changelog-9.9.9.md
+) >"$TMP/stdout" 2>"$TMP/stderr"; then
+ printf 'generate-appcast unexpectedly failed\n' >&2
+ cat "$TMP/stderr" >&2
+ exit 1
+fi
+
+OUT="$TMP/project/dist/appcast.xml"
+[ -f "$OUT" ] || { echo "no appcast written" >&2; exit 1; }
+
+grep -q '9.9.9' "$OUT"
+grep -q 'releases/download/v9.9.9/WinMice-9.9.9.zip' "$OUT"
+grep -q 'sparkle:edSignature="dGVzdHNpZ25hdHVyZQ=="' "$OUT"
+grep -q 'length="4242"' "$OUT"
+grep -q 'sparkle:format="markdown"' "$OUT"
+grep -q -- '- Added a thing.' "$OUT"
+grep -q -- '- Fixed another thing.' "$OUT"
+if grep -q '{{' "$OUT"; then
+ echo "unsubstituted placeholder in appcast" >&2
+ exit 1
+fi
+xmllint --noout "$OUT"
+
+# The private key must be handed over as a file, never inline.
+grep -q -- '-f' "$TMP/sign_update_args"
+if grep -q 'not-a-real-private-key' "$TMP/sign_update_args"; then
+ echo "private key contents leaked into the sign_update command line" >&2
+ exit 1
+fi
+
+# Missing changelog must fail rather than emit an appcast with empty notes.
+if (
+ cd "$TMP/project"
+ "$ROOT/scripts/generate-appcast.sh" 9.9.9 dist/WinMice-9.9.9.zip docs/nope.md
+) >/dev/null 2>&1; then
+ echo "expected generate-appcast to fail on a missing changelog" >&2
+ exit 1
+fi
+
+# sign_update discovery must pick the EdDSA tool by name, not by find's
+# traversal order, and must never fall back to the deprecated DSA script.
+DISCOVERY_ROOT="$TMP/discovery"
+mkdir -p "$DISCOVERY_ROOT/project/docs" "$DISCOVERY_ROOT/project/dist" "$DISCOVERY_ROOT/project/.build"
+cp "$ROOT/docs/appcast-template.xml" "$DISCOVERY_ROOT/project/docs/appcast-template.xml"
+printf 'zip contents\n' > "$DISCOVERY_ROOT/project/dist/WinMice-9.9.9.zip"
+printf -- '- Discovery case.\n' > "$DISCOVERY_ROOT/project/docs/changelog-9.9.9.md"
+
+mkdir -p "$DISCOVERY_ROOT/project/.build/artifacts/sparkle/Sparkle/bin/old_dsa_scripts"
+
+cat > "$DISCOVERY_ROOT/project/.build/artifacts/sparkle/Sparkle/bin/sign_update" <<'EOF'
+#!/bin/sh
+printf 'correct-tool-ran\n' > "$TEST_STATE/discovery_marker"
+printf 'sparkle:edSignature="ZGlzY292ZXJ5" length="99"\n'
+EOF
+
+cat > "$DISCOVERY_ROOT/project/.build/artifacts/sparkle/Sparkle/bin/old_dsa_scripts/sign_update" <<'EOF'
+#!/bin/sh
+printf 'dsa-tool-ran\n' > "$TEST_STATE/discovery_marker"
+echo "old_dsa_scripts/sign_update takes positional args, not -f" >&2
+exit 1
+EOF
+
+chmod +x "$DISCOVERY_ROOT/project/.build/artifacts/sparkle/Sparkle/bin/sign_update" \
+ "$DISCOVERY_ROOT/project/.build/artifacts/sparkle/Sparkle/bin/old_dsa_scripts/sign_update"
+
+if ! (
+ cd "$DISCOVERY_ROOT/project"
+ env -u SPARKLE_SIGN_UPDATE "$ROOT/scripts/generate-appcast.sh" 9.9.9 dist/WinMice-9.9.9.zip docs/changelog-9.9.9.md
+) >"$TMP/discovery-stdout" 2>"$TMP/discovery-stderr"; then
+ printf 'generate-appcast unexpectedly failed while discovering sign_update\n' >&2
+ cat "$TMP/discovery-stderr" >&2
+ exit 1
+fi
+
+grep -q 'correct-tool-ran' "$TMP/discovery_marker"
+grep -q 'sparkle:edSignature="ZGlzY292ZXJ5"' "$DISCOVERY_ROOT/project/dist/appcast.xml"
+rm -f "$TMP/discovery_marker"
+
+# With no non-DSA sign_update present, discovery must fail loudly rather than
+# silently falling back to the DSA script or proceeding with no tool at all.
+ZERO_ROOT="$TMP/discovery-zero"
+mkdir -p "$ZERO_ROOT/project/docs" "$ZERO_ROOT/project/dist" "$ZERO_ROOT/project/.build"
+cp "$ROOT/docs/appcast-template.xml" "$ZERO_ROOT/project/docs/appcast-template.xml"
+printf 'zip contents\n' > "$ZERO_ROOT/project/dist/WinMice-9.9.9.zip"
+printf -- '- Zero-match case.\n' > "$ZERO_ROOT/project/docs/changelog-9.9.9.md"
+
+mkdir -p "$ZERO_ROOT/project/.build/artifacts/sparkle/Sparkle/bin/old_dsa_scripts"
+cat > "$ZERO_ROOT/project/.build/artifacts/sparkle/Sparkle/bin/old_dsa_scripts/sign_update" <<'EOF'
+#!/bin/sh
+exit 1
+EOF
+chmod +x "$ZERO_ROOT/project/.build/artifacts/sparkle/Sparkle/bin/old_dsa_scripts/sign_update"
+
+if (
+ cd "$ZERO_ROOT/project"
+ env -u SPARKLE_SIGN_UPDATE "$ROOT/scripts/generate-appcast.sh" 9.9.9 dist/WinMice-9.9.9.zip docs/changelog-9.9.9.md
+) >"$TMP/zero-stdout" 2>"$TMP/zero-stderr"; then
+ echo "expected generate-appcast to fail when no EdDSA sign_update is found" >&2
+ exit 1
+fi
+
+grep -q 'expected exactly one sign_update under .build/artifacts, found 0' "$TMP/zero-stderr"
+
+printf 'generate-appcast test passed\n'
diff --git a/scripts/verify-bundle-metadata.sh b/scripts/verify-bundle-metadata.sh
index d78ea37..9c0343f 100755
--- a/scripts/verify-bundle-metadata.sh
+++ b/scripts/verify-bundle-metadata.sh
@@ -5,13 +5,39 @@ APP="${1:?usage: verify-bundle-metadata.sh }"
EXPECTED_VERSION="${2:?usage: verify-bundle-metadata.sh }"
PLIST="$APP/Contents/Info.plist"
EXPECTED_ID="cz.anibalribeiro.winmice"
+EXPECTED_FEED="https://github.com/anibalribeiro/WinMice/releases/latest/download/appcast.xml"
ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$PLIST")
SHORT=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$PLIST")
BUILD=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$PLIST")
+FEED=$(/usr/libexec/PlistBuddy -c 'Print :SUFeedURL' "$PLIST" 2>/dev/null || echo "")
+ED_KEY=$(/usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' "$PLIST" 2>/dev/null || echo "")
+INTERVAL=$(/usr/libexec/PlistBuddy -c 'Print :SUScheduledCheckInterval' "$PLIST" 2>/dev/null || echo "")
fail=0
[ "$ID" = "$EXPECTED_ID" ] || { echo "CFBundleIdentifier: got '$ID' want '$EXPECTED_ID'"; fail=1; }
[ "$SHORT" = "$EXPECTED_VERSION" ] || { echo "CFBundleShortVersionString: got '$SHORT' want '$EXPECTED_VERSION'"; fail=1; }
[ "$BUILD" = "$EXPECTED_VERSION" ] || { echo "CFBundleVersion: got '$BUILD' want '$EXPECTED_VERSION'"; fail=1; }
+[ "$FEED" = "$EXPECTED_FEED" ] || { echo "SUFeedURL: got '$FEED' want '$EXPECTED_FEED'"; fail=1; }
+[ "$INTERVAL" = "86400" ] || { echo "SUScheduledCheckInterval: got '$INTERVAL' want '86400'"; fail=1; }
+
+# An ed25519 public key is 32 bytes, so 44 base64 characters. This also catches
+# the unreplaced placeholder from build-app.sh.
+case "$ED_KEY" in
+ REPLACE_WITH_GENERATED_PUBLIC_KEY|"")
+ echo "SUPublicEDKey is unset or still the placeholder — run Sparkle's generate_keys and set SPARKLE_PUBLIC_ED_KEY in build-app.sh"
+ fail=1
+ ;;
+ *)
+ [ "${#ED_KEY}" -eq 44 ] || { echo "SUPublicEDKey: expected 44 base64 characters, got ${#ED_KEY}"; fail=1; }
+ ;;
+esac
+
+# Absent on purpose: Sparkle only shows its first-launch permission prompt when
+# this key is missing entirely.
+if /usr/libexec/PlistBuddy -c 'Print :SUEnableAutomaticChecks' "$PLIST" >/dev/null 2>&1; then
+ echo "SUEnableAutomaticChecks must not be set — its absence is what produces the permission prompt"
+ fail=1
+fi
+
exit "$fail"
diff --git a/scripts/verify-packaged-zip.sh b/scripts/verify-packaged-zip.sh
new file mode 100755
index 0000000..e7f6393
--- /dev/null
+++ b/scripts/verify-packaged-zip.sh
@@ -0,0 +1,32 @@
+#!/bin/sh
+set -eu
+
+ZIP="${1:?usage: verify-packaged-zip.sh }"
+[ -f "$ZIP" ] || { echo "missing zip: $ZIP" >&2; exit 1; }
+
+TMP="$(mktemp -d)"
+trap 'rm -rf "$TMP"' EXIT
+
+ditto -x -k "$ZIP" "$TMP"
+
+APP="$TMP/WinMice.app"
+fail=0
+
+[ -d "$APP" ] || { echo "WinMice.app is not at the root of $ZIP"; fail=1; }
+
+# A precise, self-explanatory failure for exactly this defect class, rather
+# than codesign's confusing "bundle format is ambiguous" once an archiver
+# that follows symlinks has already flattened the framework.
+if [ -d "$APP" ]; then
+ [ -L "$APP/Contents/Frameworks/Sparkle.framework/Sparkle" ] || {
+ echo "Contents/Frameworks/Sparkle.framework/Sparkle is not a symlink — archiver flattened Sparkle.framework"
+ fail=1
+ }
+fi
+
+./scripts/verify-sparkle-embedding.sh "$APP" || fail=1
+
+if [ "$fail" -eq 0 ]; then
+ echo "Verified packaged zip: $ZIP"
+fi
+exit "$fail"
diff --git a/scripts/verify-sparkle-embedding.sh b/scripts/verify-sparkle-embedding.sh
new file mode 100755
index 0000000..d429e55
--- /dev/null
+++ b/scripts/verify-sparkle-embedding.sh
@@ -0,0 +1,61 @@
+#!/bin/sh
+set -eu
+
+APP="${1:?usage: verify-sparkle-embedding.sh }"
+[ -d "$APP" ] || { echo "missing app bundle: $APP" >&2; exit 1; }
+
+FRAMEWORK="$APP/Contents/Frameworks/Sparkle.framework"
+BINARY="$APP/Contents/MacOS/WinMice"
+fail=0
+
+[ -d "$FRAMEWORK" ] || { echo "missing $FRAMEWORK"; fail=1; }
+[ -f "$FRAMEWORK/Versions/B/Sparkle" ] || { echo "missing Sparkle binary in $FRAMEWORK"; fail=1; }
+
+# A missing rpath still links and still signs; it only fails at launch.
+if ! otool -l "$BINARY" | grep -q '@loader_path/../Frameworks'; then
+ echo "main binary is missing an LC_RPATH of @loader_path/../Frameworks"
+ fail=1
+fi
+
+# Seals the framework's hashes into the app signature; catches a nested binary
+# signed after the outer bundle.
+codesign --verify --deep --strict --verbose=2 "$APP" || fail=1
+
+# Nested signature checks only apply to release builds; ad-hoc local builds have
+# no Developer ID and no hardened runtime.
+if codesign -dv --verbose=4 "$APP" 2>&1 | grep -q 'Signature=adhoc'; then
+ echo "ad-hoc build: skipping Developer ID and hardened-runtime checks on nested code"
+ exit "$fail"
+fi
+
+SPARKLE_VERSIONED="$FRAMEWORK/Versions/B"
+for nested in \
+ "$FRAMEWORK" \
+ "$SPARKLE_VERSIONED"/XPCServices/*.xpc \
+ "$SPARKLE_VERSIONED/Updater.app" \
+ "$SPARKLE_VERSIONED/Autoupdate"
+do
+ [ -e "$nested" ] || continue
+ INFO=$(codesign -dv --verbose=4 "$nested" 2>&1 || true)
+ printf '%s\n' "$INFO" | grep -q 'Developer ID Application' || {
+ echo "$nested: expected Developer ID Application identity"
+ fail=1
+ }
+ printf '%s\n' "$INFO" | grep -q 'flags=.*runtime' || {
+ echo "$nested: expected hardened runtime"
+ fail=1
+ }
+ printf '%s\n' "$INFO" | grep -q '^Timestamp=' || {
+ echo "$nested: expected a secure timestamp"
+ fail=1
+ }
+ if printf '%s\n' "$INFO" | grep -q 'Signature=adhoc'; then
+ echo "$nested: refusing ad-hoc signature"
+ fail=1
+ fi
+done
+
+if [ "$fail" -eq 0 ]; then
+ echo "Verified Sparkle embedding: $APP"
+fi
+exit "$fail"