From 6ef335661dab16aa92c76a2c2bd9841c860dc772 Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Fri, 4 Sep 2026 09:36:41 +0200 Subject: [PATCH 01/13] Embed and sign Sparkle.framework in the app bundle Co-authored-by: Cursor --- .github/workflows/ci.yml | 5 +++ Package.resolved | 15 +++++++ Package.swift | 19 ++++++++- scripts/build-app.sh | 33 +++++++++++++++- scripts/test-build-signing-mode.sh | 25 ++++++++++++ scripts/verify-sparkle-embedding.sh | 61 +++++++++++++++++++++++++++++ 6 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 Package.resolved create mode 100755 scripts/verify-sparkle-embedding.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 928708a..7d35c24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,11 @@ 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: Pages home verifier run: ./scripts/verify-pages-home.sh 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/scripts/build-app.sh b/scripts/build-app.sh index 430ae54..64c6e63 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -24,15 +24,29 @@ 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" < @@ -69,6 +83,23 @@ 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/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/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" From 77eb0285c032f11f7756ab520e0a413afa454621 Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Fri, 4 Sep 2026 09:44:16 +0200 Subject: [PATCH 02/13] Render release notes from a per-release changelog Co-authored-by: Cursor --- .github/workflows/release.yml | 4 +--- docs/changelog/1.1.0.md | 3 +++ docs/release-notes-template.md | 4 ++++ scripts/render-release-notes.sh | 22 ++++++++++++++++++++++ 4 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 docs/changelog/1.1.0.md create mode 100755 scripts/render-release-notes.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a6a5c4..804178c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -173,9 +173,7 @@ jobs: 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' 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/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" From 2bb851f094a98c85870f2766a3393100f9edb5c4 Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Fri, 4 Sep 2026 09:49:47 +0200 Subject: [PATCH 03/13] Generate and sign a Sparkle appcast for each release Co-authored-by: Cursor --- .github/workflows/ci.yml | 3 ++ docs/appcast-template.xml | 25 ++++++++++++ scripts/generate-appcast.sh | 63 ++++++++++++++++++++++++++++++ scripts/test-generate-appcast.sh | 66 ++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 docs/appcast-template.xml create mode 100755 scripts/generate-appcast.sh create mode 100755 scripts/test-generate-appcast.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d35c24..f69621c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,9 @@ jobs: ./scripts/build-app.sh --version 0.0.0-ci ./scripts/verify-sparkle-embedding.sh dist/WinMice.app + - name: Appcast generator test + run: ./scripts/test-generate-appcast.sh + - name: Pages home verifier run: ./scripts/verify-pages-home.sh 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/scripts/generate-appcast.sh b/scripts/generate-appcast.sh new file mode 100755 index 0000000..1a63f86 --- /dev/null +++ b/scripts/generate-appcast.sh @@ -0,0 +1,63 @@ +#!/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 + SIGN_UPDATE=$(find .build/artifacts -type f -name sign_update | head -n 1) +fi +[ -n "$SIGN_UPDATE" ] || { + echo "could not find sign_update under .build/artifacts — run 'swift build -c release' first" >&2 + exit 1 +} + +# 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/test-generate-appcast.sh b/scripts/test-generate-appcast.sh new file mode 100755 index 0000000..7ceb036 --- /dev/null +++ b/scripts/test-generate-appcast.sh @@ -0,0 +1,66 @@ +#!/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 + +printf 'generate-appcast test passed\n' From 5599dad8e309fb9ce6e10f8f30605fede59baa42 Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Fri, 4 Sep 2026 09:58:39 +0200 Subject: [PATCH 04/13] Choose sign_update by name rather than find order Co-authored-by: Cursor --- scripts/generate-appcast.sh | 18 ++++++--- scripts/test-generate-appcast.sh | 64 ++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/scripts/generate-appcast.sh b/scripts/generate-appcast.sh index 1a63f86..c0fd06c 100755 --- a/scripts/generate-appcast.sh +++ b/scripts/generate-appcast.sh @@ -17,12 +17,20 @@ OUT="dist/appcast.xml" # resolved, so there is nothing extra to download. SIGN_UPDATE="${SPARKLE_SIGN_UPDATE:-}" if [ -z "$SIGN_UPDATE" ]; then - SIGN_UPDATE=$(find .build/artifacts -type f -name sign_update | head -n 1) + # 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 -[ -n "$SIGN_UPDATE" ] || { - echo "could not find sign_update under .build/artifacts — run 'swift build -c release' first" >&2 - exit 1 -} # 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 diff --git a/scripts/test-generate-appcast.sh b/scripts/test-generate-appcast.sh index 7ceb036..fcf162e 100755 --- a/scripts/test-generate-appcast.sh +++ b/scripts/test-generate-appcast.sh @@ -63,4 +63,68 @@ if ( 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' From 8566bd424d95d94909421f312c70b780557340b4 Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Fri, 4 Sep 2026 10:03:29 +0200 Subject: [PATCH 05/13] Publish a signed appcast with each release Co-authored-by: Cursor --- .github/workflows/release.yml | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 804178c..26e0656 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,7 @@ jobs: echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "publish=$PUBLISH" >> "$GITHUB_OUTPUT" - - name: Require Apple signing secrets + - 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 +58,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 +97,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 }} @@ -159,6 +164,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,6 +185,7 @@ jobs: path: | ${{ steps.sum.outputs.zip }} ${{ steps.sum.outputs.dmg }} + dist/appcast.xml if-no-files-found: error - name: Render release notes @@ -185,9 +204,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 @@ -233,6 +252,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 From 5b355221143e0dae2925fefba88bf9fa4de7418d Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Fri, 4 Sep 2026 10:08:55 +0200 Subject: [PATCH 06/13] Document the update mechanism and Sparkle release prerequisites Co-authored-by: Cursor --- README.md | 16 +++++++- docs/releasing.md | 96 ++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 97 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e3d3a20..59abfd9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ WinMice icon -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 ~4 MB download — the app itself is under 1 MB, plus 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/docs/releasing.md b/docs/releasing.md index 77084f5..ecb1b93 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 @@ -120,31 +164,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 +208,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 +239,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 From ec70d25237fdc7ebd3324e49277c71a81e6f52af Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Fri, 4 Sep 2026 10:21:09 +0200 Subject: [PATCH 07/13] Add Sparkle feed URL and public key to the app bundle Co-authored-by: Cursor --- scripts/build-app.sh | 12 ++++++++++++ scripts/verify-bundle-metadata.sh | 26 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 64c6e63..5056a6e 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -18,6 +18,12 @@ 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" @@ -77,6 +83,12 @@ cat > "$CONTENTS/Info.plist" < NSAccessibilityUsageDescription WinMice 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 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" From 26c727977a32de369e9dcf3fbf1d8a2bdf33a8b9 Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Fri, 4 Sep 2026 10:28:26 +0200 Subject: [PATCH 08/13] Add UpdateController and a Check for Updates menu item Co-authored-by: Cursor --- Sources/WinMice/UpdateController.swift | 36 ++++++++++++++++++++++++++ Sources/WinMice/main.swift | 10 +++++++ 2 files changed, 46 insertions(+) create mode 100644 Sources/WinMice/UpdateController.swift diff --git a/Sources/WinMice/UpdateController.swift b/Sources/WinMice/UpdateController.swift new file mode 100644 index 0000000..f4acadd --- /dev/null +++ b/Sources/WinMice/UpdateController.swift @@ -0,0 +1,36 @@ +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 + + 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 + ) + } + + /// User-initiated check. Always reports an outcome, even when already up to date. + func checkForUpdates() { + controller.updater.checkForUpdates() + } + + var automaticallyChecksForUpdates: Bool { + get { controller.updater.automaticallyChecksForUpdates } + set { + guard newValue != controller.updater.automaticallyChecksForUpdates else { return } + objectWillChange.send() + controller.updater.automaticallyChecksForUpdates = newValue + } + } +} diff --git a/Sources/WinMice/main.swift b/Sources/WinMice/main.swift index bcf9856..aed8f69 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! @@ -151,6 +152,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 +261,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 { From 7dc04bfd38a7af15aea33ff3bc17b782aeeb350e Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Fri, 4 Sep 2026 10:40:11 +0200 Subject: [PATCH 09/13] Publish Sparkle's own automatic-check changes to SwiftUI Co-authored-by: Cursor --- Sources/WinMice/UpdateController.swift | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/Sources/WinMice/UpdateController.swift b/Sources/WinMice/UpdateController.swift index f4acadd..785354f 100644 --- a/Sources/WinMice/UpdateController.swift +++ b/Sources/WinMice/UpdateController.swift @@ -9,6 +9,13 @@ import Sparkle @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 @@ -18,6 +25,11 @@ final class UpdateController: ObservableObject { 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. @@ -25,12 +37,10 @@ final class UpdateController: ObservableObject { 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 { - guard newValue != controller.updater.automaticallyChecksForUpdates else { return } - objectWillChange.send() - controller.updater.automaticallyChecksForUpdates = newValue - } + set { controller.updater.automaticallyChecksForUpdates = newValue } } } From cd628c56214405ed3e8e611a0d6fc4db8477a834 Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Fri, 4 Sep 2026 10:49:40 +0200 Subject: [PATCH 10/13] Add an Updates section to the General settings pane Co-authored-by: Cursor --- .../WinMice/Settings/Panes/GeneralPane.swift | 17 ++++++++++++++++- Sources/WinMice/Settings/SettingsView.swift | 3 ++- .../Settings/SettingsWindowController.swift | 10 ++++++++-- Sources/WinMice/main.swift | 3 ++- 4 files changed, 28 insertions(+), 5 deletions(-) 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/main.swift b/Sources/WinMice/main.swift index aed8f69..7d27bb5 100644 --- a/Sources/WinMice/main.swift +++ b/Sources/WinMice/main.swift @@ -79,7 +79,8 @@ private final class WinMiceApp: NSObject, NSApplicationDelegate { settingsController = SettingsWindowController( settings: settings, permissions: permissions, - recorder: recorder + recorder: recorder, + updater: updater ) configureMainMenu() From 9150aa08d551b059eef75ce13172b0b4d80267a2 Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Fri, 4 Sep 2026 11:31:18 +0200 Subject: [PATCH 11/13] Package the release zip with ditto to preserve framework symlinks Co-authored-by: Cursor --- .github/workflows/ci.yml | 5 +++++ .github/workflows/release.yml | 10 +++++++++- scripts/package-release.sh | 5 +++-- scripts/verify-packaged-zip.sh | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 3 deletions(-) create mode 100755 scripts/verify-packaged-zip.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f69621c..a92fcb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,11 @@ jobs: ./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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26e0656..9ab654f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,9 +112,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: | 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/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" From 9a28afe3ff4c6bc5482fce9400079139819fbe34 Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Fri, 4 Sep 2026 12:03:26 +0200 Subject: [PATCH 12/13] Correct the download size in the README Measured on the notarized 1.1.0 artifact: 1.73 MB zip, 4.4 MB installed, 960 KB app binary. The old ~4 MB figure came from a zip that was accidentally flattening Sparkle.framework, and conflated download with installed size. Co-authored-by: Cursor --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 59abfd9..5ce7beb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ WinMice icon -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 ~4 MB download — the app itself is under 1 MB, plus the Sparkle framework that delivers updates — 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. From f4b9e6de0033e7dea09addcc46da86375e93d54b Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Fri, 4 Sep 2026 12:16:18 +0200 Subject: [PATCH 13/13] Fail early when a release has no changelog The appcast is generated from docs/changelog/.md, so a workflow_dispatch run left on the default version used to get all the way through both notarization steps before dying on a missing changelog. Check it up front instead, and stop calling the version optional. Also import Combine explicitly in UpdateController, matching the other ObservableObjects in the target rather than relying on a transitive re-export through Sparkle. Co-authored-by: Cursor --- .github/workflows/release.yml | 12 ++++++++++++ Sources/WinMice/UpdateController.swift | 1 + docs/releasing.md | 4 +++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ab654f..32b8892 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,6 +50,18 @@ jobs: echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "publish=$PUBLISH" >> "$GITHUB_OUTPUT" + - 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 }} diff --git a/Sources/WinMice/UpdateController.swift b/Sources/WinMice/UpdateController.swift index 785354f..5a2911d 100644 --- a/Sources/WinMice/UpdateController.swift +++ b/Sources/WinMice/UpdateController.swift @@ -1,3 +1,4 @@ +import Combine import Sparkle /// Thin wrapper over Sparkle's standard updater. diff --git a/docs/releasing.md b/docs/releasing.md index ecb1b93..ff8723d 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -156,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