From 179afeab2c6a8a150663ea615316a05d371ea20a Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Sun, 11 Oct 2026 18:38:22 +0200 Subject: [PATCH 1/6] feat: require a signed appcast for updates The appcast is served from main, which anyone with push access - or a stolen token - could rewrite to change the release notes or offer a link in the update dialog. Only the update archive was EdDSA-signed. Set SUVerifyUpdateBeforeExtraction and SURequireSignedFeed (Sparkle 2.9): from 1.1.0 on, the app accepts only an appcast and release notes signed with the release key, and verifies archives before extracting them. generate_appcast signs the feed automatically when a build requires it; the signature is an XML comment, so 1.0.x clients still read the feed. CONTRIBUTING now says appcast.xml must never be edited by hand. --- CONTRIBUTING.md | 2 ++ Config/AppInfo.plist | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8247f5..d764d69 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,3 +36,5 @@ One thing that will confuse you otherwise: an app launched from Xcode runs under ## Releases Releases are cut by the maintainer with `Scripts/release.sh`, which requires a Developer ID certificate, notarytool credentials, and the Sparkle signing key. Contributors do not need any of that. + +`appcast.xml` is signed. Since 1.1.0 the app rejects an appcast whose signature does not verify, so **never edit `appcast.xml` by hand** — not whitespace, not a merge-conflict resolution. Any change stops updates for every user. Pull requests that touch it will not be merged; only the release script writes it. diff --git a/Config/AppInfo.plist b/Config/AppInfo.plist index b837939..4673092 100644 --- a/Config/AppInfo.plist +++ b/Config/AppInfo.plist @@ -11,6 +11,15 @@ is invalid and makes Sparkle abort with a fatal updater error. --> SUPublicEDKey pssjO3kU2/CHGn3oOa4atzbvmFbwind07K8GZIuqOpY= + + SUVerifyUpdateBeforeExtraction + + SURequireSignedFeed + SUEnableAutomaticChecks From 7f0894a22236a96a0f8081a6d15014c9a4bc9b19 Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Sun, 11 Oct 2026 18:38:22 +0200 Subject: [PATCH 2/6] fix: make the release script refuse to publish anything inconsistent - Preflight before any build: on main and equal to origin/main, no existing tag or GitHub release, no earlier output for this version, the exact Developer ID identity present, SUPublicEDKey set (now fatal), and Sparkle's tools resolved from the version Package.resolved pins instead of whatever generate_appcast DerivedData happened to hold. - Run the unit tests before archiving. - Keep each release in dist// instead of deleting dist/ - which also deleted the previous release's dSYMs. - Build the appcast from a copy of the published one, so earlier releases stay in the feed (1.0.0 had already been dropped), without deltas. - Fatal checks that the appcast is validly signed and that the DMG matches its signature in it; generate_appcast only warns and writes an unsigned feed when it cannot sign. - Assert the exported app's team, sign the DMG with the full identity and a stable identifier, and copy the appcast into the repo itself. --- Scripts/release.sh | 211 ++++++++++++++++++++++++++++----------------- 1 file changed, 133 insertions(+), 78 deletions(-) diff --git a/Scripts/release.sh b/Scripts/release.sh index e528dc0..314917a 100755 --- a/Scripts/release.sh +++ b/Scripts/release.sh @@ -1,107 +1,142 @@ #!/bin/bash # InputPilot release pipeline: -# archive -> Developer ID export -> notarize+staple app -> DMG -> sign DMG -# -> notarize+staple DMG -> Gatekeeper check -> appcast +# preflight -> unit tests -> archive -> Developer ID export -> notarize+staple +# app -> DMG -> sign DMG -> notarize+staple DMG -> Gatekeeper check +# -> signed appcast -> signature verification # # Both the app and the DMG are notarized and stapled: the app so it launches # offline without contacting Apple, the DMG so Gatekeeper accepts the download. # -# Usage: Scripts/release.sh (e.g. Scripts/release.sh 1.0.0) +# The appcast is signed (the app sets SURequireSignedFeed). NEVER edit +# appcast.xml by hand afterwards - not even whitespace or a merge-conflict +# fix. Any change breaks the signature and stops updates for every user on +# 1.1.0 or later. Re-run generate_appcast instead. # -# One-time prerequisites (see AUDIT.md "Parked" section): -# 1. "Developer ID Application" certificate in the login keychain. +# Usage: Scripts/release.sh (e.g. Scripts/release.sh 1.1.0) +# +# One-time prerequisites: +# 1. "Developer ID Application" certificate (team T5M4XW2T24) in the +# login keychain. # 2. Notary credentials: xcrun notarytool store-credentials InputPilot \ # --apple-id --team-id T5M4XW2T24 -# 3. Sparkle EdDSA keys: run Sparkle's generate_keys once; put the public -# key into Config/AppInfo.plist (SUPublicEDKey). +# 3. Sparkle EdDSA keys in the login keychain (generate_keys); the public +# key is SUPublicEDKey in Config/AppInfo.plist. Back the private key up +# with `generate_keys -x `: every future appcast needs it. set -euo pipefail VERSION="${1:-}" if [[ -z "$VERSION" ]]; then - echo "Usage: Scripts/release.sh (e.g. 1.0.0)" >&2 - exit 1 + echo "Usage: Scripts/release.sh (e.g. 1.1.0)" >&2 + exit 1 fi REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -DIST="$REPO_ROOT/dist" -ARCHIVE="$DIST/InputPilot.xcarchive" -EXPORT_DIR="$DIST/export" -DMG="$DIST/InputPilot-$VERSION.dmg" +RELEASE_DIR="$REPO_ROOT/dist/$VERSION" +ARCHIVE="$RELEASE_DIR/InputPilot.xcarchive" +EXPORT_DIR="$RELEASE_DIR/export" +DMG="$RELEASE_DIR/InputPilot-$VERSION.dmg" +FEED_DIR="$RELEASE_DIR/feed" +SOURCE_PACKAGES="$RELEASE_DIR/SourcePackages" NOTARY_PROFILE="InputPilot" +TEAM_ID="T5M4XW2T24" +SIGNING_IDENTITY="Developer ID Application: Luca Gerlich ($TEAM_ID)" +DOWNLOAD_PREFIX="https://github.com/LucaGerlich/InputPilot/releases/download/v$VERSION/" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} cd "$REPO_ROOT" +# Everything that can stop a release is checked before anything is built, so +# a failure never leaves half a release behind. echo "==> Preflight" -if [[ -n "$(git status --porcelain)" ]]; then - echo "ERROR: working tree is not clean. Release only from a clean, committed state." >&2 - exit 1 -fi +[[ -z "$(git status --porcelain)" ]] || fail "working tree is not clean. Release only from a clean, committed state." -if ! security find-identity -v -p codesigning | grep -q "Developer ID Application"; then - echo "ERROR: no 'Developer ID Application' certificate in the keychain." >&2 - echo "Create one in the Apple Developer portal (team T5M4XW2T24) and install it, then re-run." >&2 - exit 1 -fi +BRANCH="$(git rev-parse --abbrev-ref HEAD)" +[[ "$BRANCH" == "main" ]] || fail "release from main, not '$BRANCH'." + +git fetch --quiet origin main --tags +[[ "$(git rev-parse HEAD)" == "$(git rev-parse origin/main)" ]] || fail "HEAD is not origin/main. Pull or push first, so the release matches what is published." -if ! grep -q "SUPublicEDKey" Config/AppInfo.plist || grep -A1 "SUPublicEDKey" Config/AppInfo.plist | grep -q ""; then - echo "WARNING: SUPublicEDKey in Config/AppInfo.plist is empty." >&2 - echo "Sparkle updates will not validate until you run generate_keys and fill it in." >&2 +git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null && fail "tag v$VERSION already exists locally." +[[ -z "$(git ls-remote --tags origin "refs/tags/v$VERSION")" ]] || fail "tag v$VERSION already exists on origin." +if gh release view "v$VERSION" >/dev/null 2>&1; then + fail "GitHub release v$VERSION already exists." fi -# The project file is the single source of truth for versions. Overriding -# MARKETING_VERSION here instead would leave CURRENT_PROJECT_VERSION stale, and -# Sparkle compares the build number - a release with an unchanged build number -# is never offered as an update. +# A re-run signs a new DMG with a different hash and EdDSA signature. If the +# first one was uploaded, the two would no longer match the appcast and cask. +[[ ! -e "$RELEASE_DIR" ]] || fail "$RELEASE_DIR exists. Remove it only if nothing from it was published." + +security find-identity -v -p codesigning | grep -qF "$SIGNING_IDENTITY" \ + || fail "'$SIGNING_IDENTITY' is not in the keychain." + +grep -A1 "SUPublicEDKey" Config/AppInfo.plist | grep -qE "[^<]+" \ + || fail "SUPublicEDKey in Config/AppInfo.plist is missing or empty; Sparkle would reject every update." + +# The project file is the single source of truth for versions. Sparkle +# compares the build number, so a release with an unchanged build number is +# never offered as an update. echo "==> Checking versions" -BUILD_SETTINGS="$(xcodebuild -project InputPilot.xcodeproj -target InputPilot -configuration Release -showBuildSettings 2>/dev/null)" +BUILD_SETTINGS="$(xcodebuild -project InputPilot.xcodeproj -target InputPilot -configuration Release -showBuildSettings 2>/dev/null)" \ + || fail "could not read build settings." PROJECT_VERSION="$(echo "$BUILD_SETTINGS" | awk -F' = ' '/ MARKETING_VERSION =/ {print $2; exit}' | tr -d ' ')" BUILD_NUMBER="$(echo "$BUILD_SETTINGS" | awk -F' = ' '/ CURRENT_PROJECT_VERSION =/ {print $2; exit}' | tr -d ' ')" -if [[ "$PROJECT_VERSION" != "$VERSION" ]]; then - echo "ERROR: MARKETING_VERSION in the project is '$PROJECT_VERSION' but you asked to release '$VERSION'." >&2 - echo "Update MARKETING_VERSION (and CURRENT_PROJECT_VERSION) in the project, commit, then re-run." >&2 - exit 1 -fi +[[ "$PROJECT_VERSION" == "$VERSION" ]] \ + || fail "MARKETING_VERSION in the project is '$PROJECT_VERSION' but you asked to release '$VERSION'." -if [[ -f appcast.xml ]]; then - PUBLISHED_BUILD="$(grep -o '[0-9]*' appcast.xml | grep -o '[0-9]*' | sort -n | tail -1)" - if [[ -n "$PUBLISHED_BUILD" && "$BUILD_NUMBER" -le "$PUBLISHED_BUILD" ]]; then - echo "ERROR: CURRENT_PROJECT_VERSION is $BUILD_NUMBER but the published appcast already advertises build $PUBLISHED_BUILD." >&2 - echo "Sparkle compares build numbers, so this release would never be offered as an update. Bump CURRENT_PROJECT_VERSION." >&2 - exit 1 - fi +PUBLISHED_BUILD="$( (grep -o '[0-9]*' appcast.xml || true) | grep -o '[0-9]*' | sort -n | tail -1)" +if [[ -n "$PUBLISHED_BUILD" && "$BUILD_NUMBER" -le "$PUBLISHED_BUILD" ]]; then + fail "CURRENT_PROJECT_VERSION is $BUILD_NUMBER but the appcast already advertises build $PUBLISHED_BUILD. Bump it." fi -echo "Releasing $VERSION (build $BUILD_NUMBER)" +echo "Releasing $VERSION (build $BUILD_NUMBER) from $(git rev-parse --short HEAD)" +mkdir -p "$RELEASE_DIR" + +# Sparkle's tools come from the exact version Package.resolved pins. +echo "==> Resolving Sparkle tools" +xcodebuild -resolvePackageDependencies -project InputPilot.xcodeproj -scheme InputPilot \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES" >/dev/null +SPARKLE_BIN="$SOURCE_PACKAGES/artifacts/sparkle/Sparkle/bin" +[[ -x "$SPARKLE_BIN/generate_appcast" && -x "$SPARKLE_BIN/sign_update" ]] \ + || fail "Sparkle's generate_appcast and sign_update not found in $SPARKLE_BIN." -rm -rf "$DIST" -mkdir -p "$DIST" +# UI tests need an unlocked desktop session and run on CI instead. +echo "==> Running unit tests" +xcodebuild -quiet test -project InputPilot.xcodeproj -scheme InputPilot -destination 'platform=macOS' \ + -only-testing:InputPilotTests CODE_SIGN_IDENTITY=- \ + || fail "unit tests failed." echo "==> Archiving Release build" xcodebuild -project InputPilot.xcodeproj \ - -scheme InputPilot \ - -configuration Release \ - -destination 'generic/platform=macOS' \ - -archivePath "$ARCHIVE" \ - archive + -scheme InputPilot \ + -configuration Release \ + -destination 'generic/platform=macOS' \ + -archivePath "$ARCHIVE" \ + archive echo "==> Exporting with Developer ID" xcodebuild -exportArchive \ - -archivePath "$ARCHIVE" \ - -exportOptionsPlist Scripts/ExportOptions.plist \ - -exportPath "$EXPORT_DIR" + -archivePath "$ARCHIVE" \ + -exportOptionsPlist Scripts/ExportOptions.plist \ + -exportPath "$EXPORT_DIR" APP="$EXPORT_DIR/InputPilot.app" echo "==> Verifying signature" codesign --verify --deep --strict --verbose=2 "$APP" +APP_TEAM="$(codesign -dvv "$APP" 2>&1 | awk -F= '/^TeamIdentifier=/ {print $2}')" +[[ "$APP_TEAM" == "$TEAM_ID" ]] || fail "the exported app is signed by team '$APP_TEAM', expected $TEAM_ID." # The app is notarized and stapled BEFORE it goes into the DMG. Stapling only # the DMG leaves the copied-out app without a ticket, so its first launch needs # a network round-trip to Apple and fails when the user is offline. echo "==> Notarizing app (this can take a few minutes)" -APP_ZIP="$DIST/InputPilot-app.zip" +APP_ZIP="$RELEASE_DIR/InputPilot-app.zip" ditto -c -k --keepParent "$APP" "$APP_ZIP" xcrun notarytool submit "$APP_ZIP" --keychain-profile "$NOTARY_PROFILE" --wait rm -f "$APP_ZIP" @@ -111,7 +146,7 @@ xcrun stapler staple "$APP" xcrun stapler validate "$APP" echo "==> Packaging DMG" -DMG_STAGING="$DIST/dmg" +DMG_STAGING="$RELEASE_DIR/dmg" mkdir -p "$DMG_STAGING" cp -R "$APP" "$DMG_STAGING/" ln -s /Applications "$DMG_STAGING/Applications" @@ -120,7 +155,7 @@ hdiutil create -volname "InputPilot" -srcfolder "$DMG_STAGING" -ov -format UDZO # An unsigned DMG is rejected by Gatekeeper on its own terms, so sign the # container too, then notarize and staple it. echo "==> Signing DMG" -codesign --force --sign "Developer ID Application" --timestamp "$DMG" +codesign --force --sign "$SIGNING_IDENTITY" --identifier com.lucagerlich.InputPilot.dmg --timestamp "$DMG" codesign --verify --strict --verbose=2 "$DMG" echo "==> Notarizing DMG (this can take a few minutes)" @@ -133,30 +168,50 @@ xcrun stapler validate "$DMG" echo "==> Verifying Gatekeeper acceptance" spctl --assess --type open --context context:primary-signature -v "$DMG" -echo "==> Generating Sparkle appcast" -# Sparkle downloads the DMG from the GitHub release asset URL, not from the -# repo tree, so the enclosure URLs must carry that prefix. -DOWNLOAD_PREFIX="https://github.com/LucaGerlich/InputPilot/releases/download/v$VERSION/" -GENERATE_APPCAST="$(find "$HOME/Library/Developer/Xcode/DerivedData" -path "*artifacts*Sparkle*/bin/generate_appcast" -print -quit 2>/dev/null || true)" -if [[ -n "$GENERATE_APPCAST" ]]; then - "$GENERATE_APPCAST" --download-url-prefix "$DOWNLOAD_PREFIX" "$DIST" - echo "Appcast written to $DIST/appcast.xml" -else - echo "WARNING: generate_appcast not found in DerivedData; build once in Xcode or download Sparkle's tools, then run:" >&2 - echo " generate_appcast --download-url-prefix $DOWNLOAD_PREFIX $DIST" >&2 -fi +# Only the new DMG goes into the feed folder, next to a copy of the published +# appcast: generate_appcast keeps the earlier items as they are, URLs +# included, and adds this release. It signs the feed because the new build +# requires a signed one. No deltas - each would be another asset to upload. +echo "==> Generating the signed Sparkle appcast" +mkdir -p "$FEED_DIR" +cp "$DMG" "$FEED_DIR/" +cp appcast.xml "$FEED_DIR/appcast.xml" +"$SPARKLE_BIN/generate_appcast" --download-url-prefix "$DOWNLOAD_PREFIX" --maximum-deltas 0 "$FEED_DIR" +cp "$FEED_DIR/appcast.xml" appcast.xml + +# generate_appcast only warns when it cannot sign (key missing, Keychain +# prompt denied) and writes an unsigned feed - which 1.1.0+ clients reject. +echo "==> Verifying the appcast and update signatures" +"$SPARKLE_BIN/sign_update" --verify appcast.xml || fail "appcast.xml is not validly signed. Do not publish it." + +ENCLOSURE_SIGNATURE="$(/usr/bin/python3 -I - appcast.xml "$VERSION" <<'PY' +import sys +# Parses the appcast generate_appcast just wrote on this machine - not +# untrusted input - so the stdlib parser is fine here. +import xml.etree.ElementTree as ET + +ns = {"sparkle": "http://www.andymatuschak.org/xml-namespaces/sparkle"} +path, version = sys.argv[1], sys.argv[2] +for item in ET.parse(path).getroot().iter("item"): + if item.findtext("sparkle:shortVersionString", namespaces=ns) == version: + print(item.find("enclosure").get("{%s}edSignature" % ns["sparkle"])) + break +PY +)" +[[ -n "$ENCLOSURE_SIGNATURE" ]] || fail "appcast.xml has no item with an EdDSA signature for $VERSION." +"$SPARKLE_BIN/sign_update" --verify "$DMG" "$ENCLOSURE_SIGNATURE" \ + || fail "$DMG does not match its signature in appcast.xml. Do not publish either." DMG_SHA="$(shasum -a 256 "$DMG" | cut -d' ' -f1)" echo "" -echo "==> Homebrew cask update (LucaGerlich/homebrew-tap, Casks/inputpilot.rb):" -echo " version \"$VERSION\"" -echo " sha256 \"$DMG_SHA\"" - +echo "==> Done. $DMG is signed, notarized and verified; appcast.xml is updated and signed." echo "" -echo "==> Done. Release checklist:" -echo " 1. git tag v$VERSION && git push origin v$VERSION" -echo " 2. Create a GitHub Release for v$VERSION and attach: $DMG" -echo " 3. Commit the updated appcast.xml to main (the SUFeedURL points at it)." -echo " 4. Update the Homebrew cask with the version and sha256 printed above." -echo " 5. Verify Gatekeeper on another Mac: spctl --assess --type open --context context:primary-signature -v $DMG" +echo "Release checklist, in this order:" +echo " 1. git tag -a v$VERSION -m \"InputPilot $VERSION\" $(git rev-parse HEAD) && git push origin v$VERSION" +echo " 2. gh release create v$VERSION \"$DMG\" --verify-tag --title \"InputPilot $VERSION\" --notes " +echo " 3. Immediately after: commit appcast.xml exactly as written (no edits) to main, via PR, and merge." +echo " 4. Homebrew cask (LucaGerlich/homebrew-tap, Casks/inputpilot.rb):" +echo " version \"$VERSION\"" +echo " sha256 \"$DMG_SHA\"" +echo " 5. Check for Updates in the previous version offers $VERSION and installs it." From 2a2c47383bbbfbdb771840ab2a367087ff4caa4f Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Sun, 11 Oct 2026 18:38:22 +0200 Subject: [PATCH 3/6] ci: pin the runner, Xcode and checkout; least-privilege token - macos-26 and Xcode 26.6, the toolchain releases are built with, instead of macos-latest and whichever Xcode sorts last (a beta, soon). - actions/checkout pinned to the v7.0.1 commit SHA. - Read-only token, a concurrency group and a 30 minute timeout. --- .github/workflows/ci.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8020843..ca99166 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,14 +5,24 @@ on: branches: [main, dev] pull_request: +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: build-and-test: - runs-on: macos-latest + # Pinned to the image and Xcode that releases are built with; bump both + # deliberately rather than inheriting a new macOS or an Xcode beta. + runs-on: macos-26 + timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Select Xcode - run: sudo xcode-select -s "$(ls -d /Applications/Xcode*.app | sort -V | tail -1)" + run: sudo xcode-select -s /Applications/Xcode_26.6.app - name: Build and test run: | From 99773017d05a3a3374909ef3147d2b87936bc197 Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Sun, 11 Oct 2026 18:38:22 +0200 Subject: [PATCH 4/6] chore: drop an unused entitlement and ignore local Claude settings ENABLE_USER_SELECTED_FILES produced a user-selected-files entitlement that does nothing without the sandbox. --- .gitignore | 3 +++ InputPilot.xcodeproj/project.pbxproj | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index a73b5cb..525c686 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ xcuserdata/ dist/ *.dmg +# Local Claude Code settings +.claude/settings.local.json + # Test artifacts .playwright-mcp/ landing-*.png diff --git a/InputPilot.xcodeproj/project.pbxproj b/InputPilot.xcodeproj/project.pbxproj index 3d520bf..5a41401 100644 --- a/InputPilot.xcodeproj/project.pbxproj +++ b/InputPilot.xcodeproj/project.pbxproj @@ -410,7 +410,6 @@ ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/AppInfo.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; @@ -445,7 +444,6 @@ ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/AppInfo.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; From aae58592d9c43b457baa76969932d51caaf2c6b3 Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Sun, 11 Oct 2026 18:38:22 +0200 Subject: [PATCH 5/6] docs: add the post-launch audit with its resolution status Findings from 2026-10-11, with what 1.1.0 fixed ticked, and what is partly done, needs hardware, needs Luca, or is deferred on purpose. --- docs/post-launch-audit.md | 255 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 docs/post-launch-audit.md diff --git a/docs/post-launch-audit.md b/docs/post-launch-audit.md new file mode 100644 index 0000000..5085294 --- /dev/null +++ b/docs/post-launch-audit.md @@ -0,0 +1,255 @@ +# InputPilot Post-Launch Audit + +Audited: 2026-10-11 · Scope: `main` @ `7574e1a` (v1.0.1, live since 2026-08-18), the website, the release pipeline, the Homebrew tap, and public reach +Method: 5 parallel reviews (correctness/concurrency, security/privacy/claims, distribution/release, tests/coverage, UI/first-run), then every High and most Medium findings re-verified against the code by hand. Discoverability was measured directly (GitHub API, web search, competitor data). + +Verification baseline: **build ✓ · 35 unit + 2 UI tests pass ✓ · stable over 100 runs and under CPU load ✓ · shipped DMG verified (sha256 = cask = release asset, Developer ID, notarized, stapled, EdDSA signature valid) ✓ · line coverage 48.9 % ✗** + +Previous audit: [pre-release-audit.md](pre-release-audit.md) (23 findings, all resolved before 1.0.0). + +## Resolution status (2026-10-11) + +Fixed in three pull requests and shipped as **1.1.0**: [#13](https://github.com/LucaGerlich/InputPilot/pull/13) (public claims, log flood, F6), [#14](https://github.com/LucaGerlich/InputPilot/pull/14) (the 1.1.0 product work), and the release-hardening PR that adds this section. Ticked boxes below are resolved. Unit tests went from 35 to 69, all written test first. + +- **Partly done:** T1's coverage target (logic is tested; SwiftUI views stay low by design). L9: the always-true assertion and the empty UI test are fixed, but the test host still boots the real app. L12: the team is asserted and the DMG identifier is fixed, but the notary log is not fetched. L14: AppState is 1,150 lines, still over the 800-line limit. L15: gitignore and entitlement are fixed; stale branches remain. M2: signed feed done; branch protection on `main` is pending Luca's go-ahead. +- **Needs a human at the hardware:** P4 with Karabiner-Elements actually installed, and the per-policy check with two keyboards. +- **Needs Luca (DNS, payment, accounts, his voice):** R1 launch, R2 Search Console and Bing, R3 AlternativeTo and the awesome-list PRs, R4 repo metadata, M12 Pages domain verification, L11 the cask's `uninstall quit:` (done at release time in the tap repo). +- **Deferred on purpose:** L2 CJK activation (needs a repro), L3 refusing a blind overwrite, L4 the blocking permission request, L8 Swift 6 mode, L13 Sparkle 2.10, and before-keypress switching (it would need the Accessibility permission, a separate product decision). + +## Summary + +The shipped artifact is sound, and the privacy core holds up: nothing derived from a keystroke reaches the log, storage, clipboard or network. The problems fall into three groups: + +1. **Nobody is finding it.** In 8 weeks: 8 DMG downloads, 0 stars, 0 issues. The launch was never carried out, and the site isn't in the search index. Meanwhile a direct competitor, Keychange (public 3 weeks earlier), has about 5,900 downloads. +2. **Gaps a first-time user hits in the first hour:** + - no launch at login; + - a manual layout change is undone on the next keystroke; + - after the permission relaunch, nothing tells the user what to do; + - a single failed keyboard (e.g. Karabiner-Elements) disables everything. +3. **Public claims that the code doesn't back up.** This repeats the precision problem fixed in August. One of the claims is the very sentence that was corrected then. + +| Severity | Count | Theme | +|----------|-------|-------| +| High | 7 | 1 reach, 4 first-hour product gaps, 1 false claims, 1 untested regression | +| Medium | 12 | Same-model keyboards, update-feed trust, release script, UI flow | +| Low | 16 | Robustness, CI, hygiene, wording | + +--- + +## Reach and discoverability + +### Data (2026-10-11) + +| Signal | InputPilot | Keychange (`dennistimmermann/keychange`) | +|---|---|---| +| Public since | 2026-08-18 | 2026-07-29 | +| Downloads | 8 (v1.0.1: 6, v1.0.0: 2) | ~5,900 (v0.11.0 alone) | +| Stars | 0 | 17 | +| Repo views, last 14 days | 4 (2 unique) | — | +| Search: own name | **No result** | Ranks, with AlternativeTo page | +| Search: "mac switch keyboard layout per keyboard" | Absent | #1 recommendation | +| AlternativeTo | Not listed | Listed | +| Launch at login | ✗ | ✓ | +| Switch before keypress | ✗ (400 ms after) | ✓ optional (Accessibility) | +| Respects manual layout change | ✗ (reverts) | ✓ 4 policies | +| macOS floor | **13** | 14 | + +Neither big open-source Mac list ([open-source-mac-os-apps](https://github.com/serhii-londar/open-source-mac-os-apps), [awesome-mac](https://github.com/jaywcjlove/awesome-mac)) contains any per-keyboard switcher yet, including Keychange and autokbisw. + +### Findings + +- [ ] **R1 · High · The launch never happened.** The channel plan from 2026-08-18 (Show HN, r/macapps, Product Hunt, AlternativeTo) was never executed. The on-page SEO is fine (title, description, canonical, OG, JSON-LD, sitemap), but a direct search for the product name and the domain returns nothing. On-page tags do nothing until something links to the page. + - **Fix order:** + 1. Fix P1–P4 and C1 first. A Show HN thread is a one-shot event, and those are exactly what a commenter would find in the first hour. + 2. Do the zero-cost items below. + 3. Then launch. +- [ ] **R2 · Medium · Not submitted to search engines.** Verify the domain in Google Search Console and Bing Webmaster Tools, then submit `sitemap.xml`. Bing also feeds DuckDuckGo and ChatGPT search. *Needs Luca: a DNS TXT record at Namecheap.* +- [ ] **R3 · Medium · Absent from the pages that already rank.** + - Add InputPilot on AlternativeTo as an alternative to Keychange and autokbisw, and pay the $5 priority review. "Keychange Alternatives" is itself a ranking result for this niche. + - Open PRs to both open-source Mac lists, in their input-source sections. InputPilot would be the first per-keyboard switcher in either. +- [ ] **R4 · Medium · Repo metadata works against discovery.** + - The homepage URL still points to the old `lucagerlich.github.io/InputPilot/` (redirect). Change it to `https://inputpilot.lucagerlich.dev`. + - The description says "tracks what keyboard you use". For an app that asks for Input Monitoring, that reads like spyware, and it misses the phrase people search for. Proposed: *"Automatic keyboard layout switching for macOS: one input source per physical keyboard, switched as you type."* + - Add the topics `input-method`, `keyboard-switcher`, `menubar-app` and `multilingual`. + - Upload a custom social-preview image (1280×640) rendered from the ASCII keyboard. `Scripts/generate-keyboard-art.py` already produces the art. Links shared on HN, Reddit or X currently show GitHub's generic card, and the site's `og:image` is the square icon with `twitter:card=summary`. +- [x] **R5 · Medium · The README doesn't mention Homebrew.** Commit `c651524` ("docs: document Homebrew installation") sits unmerged on `docs/public-polish`. Homebrew users read the README, not the site. +- [x] **R6 · Medium · Positioning ignores the main competitor.** + - The site's "How is this different from…" FAQ names autokbisw, OctoKeys and Input Source Pro, but not Keychange. Keychange is the one a reader will actually compare against. + - The honest differentiators today are weak: macOS 13 support, explicit mapping with per-device and global fallbacks, and the debug log. + - Closing P1 and P2 removes the reasons to pick Keychange instead. Fixing M1 (same-model keyboards) would give InputPilot a real differentiator. + - Name Keychange in the FAQ. Being named first reads as confidence; being corrected in a launch thread doesn't. + +--- + +## High + +- [x] **P1 · No launch at login.** There is no `SMAppService` or login-item code anywhere. After a reboot, switching silently stops until the user starts the app by hand. Every menu bar utility is expected to have this, and Keychange does. + - Fix: add an "Open at Login" toggle (Settings, plus the final Welcome state) backed by `SMAppService.mainApp.register()/unregister()` (macOS 13+). Show `.requiresApproval` when it applies. + - *Verified.* +- [x] **P2 · A manual input-source change, Undo, or an IME mode toggle is undone by the next keystroke.** + - `AppState.swift:745-749` and `:789-794` re-evaluate the mapping on every non-modifier keyDown, reading the current source fresh. The TIS change observer (`:641-650`) only refreshes the display. + - Effects: + - a multilingual user who presses ⌃Space to type one word in another language is switched back 400 ms later; + - "Undo last switch" has no lasting effect (`:363-379`); + - a keyboard mapped to Kotoeri loses Eisu/Kana, and Caps Lock "switch to ABC" is reverted mid-word. + - Fix: + - After a manual change, Undo, or a change not made by InputPilot, suppress auto-switching for that device until the active keyboard changes. That is Keychange's "Ignore" policy and the narrowest fix that keeps H1's fresh-read. + - Treat input modes of the same input method (same `kTISPropertyBundleID`) as already satisfying the mapping. + - *Verified.* +- [x] **P3 · The most common first-run path ends with no guidance.** + - The Welcome window only opens while permission is missing (`InputPilotApp.swift:46`). The typical path is: grant, then macOS asks for "Quit & Reopen". After that relaunch, no window appears. The user sees a keyboard icon, has no mappings, and gets no hint. + - The relaunch state's only button is "Quit InputPilot" (`WelcomeView.swift:87`). The app has no Dock icon, so it simply disappears. + - Fix: + - Persist `hasCompletedOnboarding` and show the Welcome window until it is set. + - Turn the button into "Relaunch InputPilot". + - Add "Open Settings" to the final state. + - *Verified.* +- [x] **P4 · One keyboard that can't be opened disables all monitoring (Karabiner-Elements).** + - `HIDKeyboardMonitor.swift:53-63` discards the whole manager when `IOHIDManagerOpen` returns any error. + - Per Apple's IOHIDManager.c, that call returns the first per-device error even though the other devices opened. Karabiner-Elements seizes physical keyboards, so the open returns `kIOReturnExclusiveAccess`. InputPilot then shows "HID monitor start failed with IOReturn -536870203", and its "relaunch" advice can never help. + - Karabiner users who own several keyboards are a core audience for this app. + - Fix: + - Keep the manager when `IOHIDManagerCopyDevices` is non-empty. + - Map `kIOReturnExclusiveAccess` to "A keyboard is held exclusively by another app (e.g. Karabiner-Elements)". + - Document the interaction. + - *Code path verified; the IOKit behaviour comes from Apple's source; not reproduced with Karabiner installed.* +- [x] **C1 · Public claims the code contradicts.** The product's pitch is precision about the scariest macOS permission. The verified false sentences: + + | Where | Says | Reality | Replace with | + |---|---|---|---| + | `docs/index.html:202` (JSON-LD `featureList`, the text search engines quote) | "Reads only the source device and modifier flag, **never key codes** or typed text" | `HIDKeyboardMonitor.swift:108` reads the usage code. This is the exact claim corrected on 2026-08-18. | "Reads the source device and a modifier flag; the key's usage code is checked only to detect modifiers and is never stored, logged or transmitted" | + | `WelcomeView.swift:35`, shown at the moment of trust | "Nothing leaves your Mac." | Sparkle checks GitHub automatically every day (`SUEnableAutomaticChecks`=true) | "Nothing you type leaves your Mac. The only network request is a daily update check." | + | `docs/index.html:497`, `CHANGELOG.md:27` (`README.md:40` is already accurate) | "Modifier-only presses are ignored, so ⌘-Tab never changes your layout" | Tab is not a modifier; ⌘-Tab on the other keyboard switches | "Modifier-only presses (a lone ⇧ or ⌘) are ignored; any other key, including the Tab in ⌘-Tab, counts as typing on that keyboard." | + | `docs/index.html` FAQ ("German external keyboard…") | "the layout is right **the moment you start typing**" | The switch fires 400 ms after the first keypress, so the first character or two land in the old layout | "within half a second of the first key press" | + | `CHANGELOG.md:29` | "over a signed appcast" | Only the enclosure is EdDSA-signed; the feed is not (see M2) | "every update archive is EdDSA-signed and verified before install" | + | `SECURITY.md:24` | "…and Apple's notarization of the downloaded app" | Sparkle checks EdDSA plus the code signature, not notarization | "Sparkle verifies an EdDSA signature over the archive and that the new app is code-signed by the same Developer ID team. Each release is also notarized." | + | `docs/pre-release-audit.md:8` | "user-initiated update checks are now the app's only network activity" | Checks are automatic | Add a correction note | + + Wording cleanups (true, but easy to quote against you): + - "never reads what you type" sits next to "the usage code is read": say "never **records** what you type". + - `SECURITY.md:12`: the usage code "is never assigned", yet it is assigned to a local constant. + - `SECURITY.md:18`: describes redaction "at export"; in fact a 5-word filter runs when each entry is recorded. + - `SECURITY.md:16`: the storage list ends "That is all" but leaves out the flags, the fallbacks, the `.corrupted` backup and Sparkle's keys. + - "read every line of the thing you are granting access to": Sparkle runs in the same process, so add "plus the open-source Sparkle framework". +- [x] **T1 · The v1.0.1 first-run fix has no regression test, and coverage is 48.9 %.** + - Mutation check: removing `openWindow(id: "welcome")`, or making `requestInputMonitoringPermission()` never call `IOHIDRequestAccess`, leaves all 35 tests green. That is exactly the 1.0.0 failure, where no new user could grant the permission. + - Overall: 7 of 10 deliberate mutations went undetected. + - Coverage: overall 48.9 %; testable logic 76.4 %; `HIDKeyboardMonitor` 1.1 %; `SettingsView` 0.3 %. The WelcomeView and InputPilotApp numbers come from the test host booting the real app, not from assertions. + - Smallest fixes: + - a unit test from `kIOHIDAccessTypeUnknown` asserting the request count is 1; + - a UI test asserting `app.windows["Welcome to InputPilot"].waitForExistence(timeout: 5)`. This is deterministic on CI, because ad-hoc-signed builds never hold Input Monitoring. + +## Medium + +- [x] **M1 · Two keyboards of the same model can't be mapped separately.** + - `AppState.swift:957-959`: when there is exactly one primary match, the new device is folded into it whatever its LocationID. Example: two Magic Keyboards, one US and one German, share one Settings row and one mapping. + - The location tie-breaker (`:930-933`) only runs when both keys already exist, which the UI can't produce. The test inserts both directly. + - Fix: fold only when no currently attached device holds the existing key's location (query `IOHIDManagerCopyDevices`). This keeps "recognised again after moving to another port" working. + - *Verified.* +- [x] **M2 · Anyone who can push to `main` controls the update dialog.** + - The appcast is unsigned: neither `SURequireSignedFeed` nor `SUVerifyUpdateBeforeExtraction` is set. + - It is served from `main`, which is unprotected (`branches/main.protected=false`, no rulesets). + - Without the EdDSA key nobody can ship a malicious binary. But a stolen GitHub token could put phishing links into the release notes, or add an informational item that opens any URL. + - Fix: + - set both keys; + - add a `sign_update appcast.xml` step to `release.sh` (once enabled, every future feed must be signed or updates stop); + - protect `main`. +- [x] **M3 · Automatic update checks can't be turned off and aren't disclosed** (see C1). Add a Settings toggle bound to `updater.automaticallyChecksForUpdates`. +- [x] **M4 · Non-keyboard "input sources" can be chosen as mapping targets.** + - On the audit machine, the enabled-sources query returns Emoji & Symbols, Dictation, PressAndHold and EmojiFunctionRowIM, and all of them appear in the pickers. + - If one is mapped, `TISSelectInputSource` "succeeds" but the current keyboard source never matches, so the source is re-selected after every 1.5 s cooldown. + - Fix: filter results to `kTISCategoryKeyboardInputSource`. Also, `listAllInputSources` passes `includeAllInstalled: false`, so conflict rows show raw IDs. +- [x] **M5 · Each release wipes the appcast history and the old dSYMs** (`Scripts/release.sh:78` `rm -rf "$DIST"` before `generate_appcast`). + - 1.0.0 is already gone from the feed. That means no delta updates, no fallback item once a release raises the minimum macOS, and crash logs from old versions can't be symbolicated. + - Fix: `cp appcast.xml "$DIST/"` before generating, and keep archives in a git-ignored `releases/$VERSION/`. + - *Verified.* +- [x] **M6 · A release isn't tied to a pushed commit or tag.** + - The script doesn't check the branch, that HEAD equals `origin/main`, or that the tag or GitHub release doesn't already exist. + - Re-running after uploading produces a DMG with a different hash and signature, which mismatches the cask and Sparkle. + - Fix: add all of these to the preflight checks. +- [x] **M7 · Two security checks in `release.sh` only warn and let the release continue.** + - An empty `SUPublicEDKey` (`:47`) only prints a warning. + - A missing `generate_appcast` (`:140-147`) is reported after both notarizations, and the script still prints "Done". The tool is found with an unpinned glob over all of DerivedData, and it reads the EdDSA private key. + - Fix: make both fatal in preflight, resolve the tool from this project's SourcePackages, and copy the appcast to the repo root. +- [x] **M8 · Windows opened from the menu aren't brought to the front** (`MenuBarMenuView.swift:65, 115, 171`). The app is LSUIElement, so Settings can open behind the frontmost app, with no Dock icon or ⌘-Tab entry to reach it. Fix: call `NSApp.activate(ignoringOtherApps: true)` after `openWindow`. +- [x] **M9 · The core task can't be found from the menu.** + - The menu has about 20 items, mostly diagnostics (Vendor/Product/Location ID, source IDs). + - There is no "Typing on X → Not mapped" row. "Settings…" comes after "Quit". Mappings are the 6th section in Settings, below the fold. + - With only one keyboard attached, nothing explains that the app needs a second one. + - Fix: + - show the active-keyboard row first, with "Use current input source for X"; + - move the IDs into Debug; + - put mappings at the top of Settings and Quit last. +- [x] **M10 · After a denial, the main button does nothing** (`WelcomeView.swift:54-71`). TCC prompts only once. Fix: in the `.denied` state, make "Open System Settings" the default button and change the text to "switch InputPilot on". +- [x] **M11 · The debug log floods while the monitor can't start.** + - `AppState.swift:684-685` logs the error on every 1 s poll, outside the deduplication check at `:680`. + - Within about 200 s the 200-entry log, which users are asked to attach to bug reports, contains nothing else. + - Fix: move both lines inside the `if`. + - *Verified; three reviewers reported it independently.* +- [ ] **M12 · The GitHub Pages domain is not verified** (`protected_domain_state: null`). If Pages is ever disabled, `inputpilot → lucagerlich.github.io` dangles, and anyone could serve a fake download page that the About screen links to. Fix: verify `lucagerlich.dev` under GitHub → Settings → Pages. *Needs Luca: a DNS TXT record.* + +## Low + +- [x] **L1 · F6 is treated as a modifier.** `HIDKeyboardMonitor.swift:171` `usage == 0x3F`. On the HID keyboard page, `0x3F` is F6 (confirmed: `kHIDUsage_KeyboardF6`); `0x3F` is Fn only as a Carbon *virtual* keycode. The real Fn/Globe key is on Apple's vendor page and is already filtered out. Fix: delete the line. +- [ ] **L2 · CJK switches can be recorded as done without taking effect.** This is a known macOS bug, documented by Kawa and macism. Reproduce it, then work around it or document it. *Medium confidence, not reproduced.* +- [ ] **L3 · H2 is only partly fixed** (`MappingStore.swift:119-129, 176-178`). Corrupt data still loads as `[:]`, and the next write overwrites it. The user isn't told, and only the first corruption is backed up. The audit's "refuse blind overwrite" was never implemented. +- [ ] **L4 · `IOHIDRequestAccess` blocks the main thread** until the prompt is answered (`PermissionService.swift:10`). Call it from a detached task. +- [x] **L5 · The Settings "Pause" toggle pauses for 365 days**, and the menu shows only "Paused until 14:32", with no date. Two on/off concepts overlap. Use one "Enabled" toggle. +- [x] **L6 · Accessibility.** The menu bar image has no `accessibilityLabel`. Per-device pickers share identical labels. Decorative icons aren't hidden. The icon itself never changes when paused or when permission is missing, so `README.md:64` ("Check the menu bar icon: it shows…") only works if you read it as "open the menu". A status-tinted icon would make it literally true. +- [x] **L7 · Jargon in user-facing strings.** Examples: "VID 1452 | PID 641 | Location 336592896", "kIOReturnNotPermitted", "missing/disabled", "Device Fallback (Advanced)". +- [ ] **L8 · Swift 5 language mode.** With `SWIFT_STRICT_CONCURRENCY=complete` there are 3 warnings that become errors in Swift 6: `AppState.swift:141` (non-Sendable observer in a nonisolated deinit) and `HIDKeyboardMonitor.swift:95` (main-actor `stop()` called from deinit). +- [ ] **L9 · Test hygiene.** + - The test host boots the real `AppState`, starts Sparkle and uses the real `com.lucagerlich.InputPilot` defaults domain. + - The UI test asserts nothing. + - `AppStateFailurePathTests.swift:33` compares a non-optional value to `nil`, so the assertion is always true. + - `waitFor` helpers time out silently. + - Untested: M2's "wrong device removed" case, same-device repeated keys in SwitchController (`:59-62`), and Settings persistence. +- [x] **L10 · CI floats `macos-latest`** plus "newest Xcode", which will pick up a beta. `actions/checkout@v5` is tag-pinned and two majors behind. There's no `permissions:`, `concurrency:` or `timeout-minutes`, and no coverage gate. +- [ ] **L11 · The cask has no `uninstall quit: "com.lucagerlich.InputPilot"`.** `brew uninstall` removes the bundle while the agent keeps running. *(Fix this in the tap repo.)* +- [ ] **L12 · `release.sh` diagnostics.** + - A rejected notarization is only caught when stapling fails, and the notary log is never fetched. + - The team (`T5M4XW2T24`) is never asserted after export. + - The DMG's signing identifier is `InputPilot-1`. + - `:11` points to a "Parked section" of an `AUDIT.md` that doesn't exist. +- [ ] **L13 · Sparkle 2.10.0 is available** (you ship 2.9.6). It has no security fixes; bump it at the next release. +- [ ] **L14 · AppState.swift is 1,115 lines** (the limit is 800). Extract a pure `DeviceKeyResolver` (~130 lines, which also makes M1 testable), the permission and monitor lifecycle, and the display strings. +- [ ] **L15 · Repo hygiene.** + - Merged branches still exist: `origin/chore/appcast-1.0.0`, `origin/chore/homebrew-tap`, plus local ones. + - `origin/dev` is the known stale V2. + - `.claude/settings.local.json` is ignored only by the global gitignore. + - The app has an unused `files.user-selected.read-only` entitlement. +- [x] **L16 · `docs/pre-release-audit.md` is served publicly** and names the work account's team ID. Team IDs aren't secret, but this one links the personal project to the employer's account. + +## Verified good + +- **Shipped artifact:** + - The DMG sha256 matches across `dist/`, the cask and the GitHub asset digest. + - Developer ID T5M4XW2T24, timestamped, notarized and stapled. + - `spctl` accepts both the DMG and the app. + - Universal binary, hardened runtime; `--deep --strict` passes, including Sparkle's XPC services. + - The appcast EdDSA signature verifies against the shipped `SUPublicEDKey`. + - Version, build and minimum OS (13.0) are consistent across project, binary, appcast and cask. +- **Privacy core:** + - Only device properties and one Bool leave the HID callback. + - There is no per-keystroke logging and no networking outside Sparkle. + - Every opened URL is a constant. + - Export and clipboard happen only on explicit user action, and the log is in memory and capped. + - The site loads no third-party scripts, fonts or analytics. +- **Previous fixes hold:** H1 (fresh source read, TIS observer fires for a background agent), M1, M2, M3, and HID callback lifetime (no use-after-free on stop or restart). +- **Concurrency and robustness:** + - Main-run-loop callbacks hop to `@MainActor`, and no data races were found. + - Debounce cancellation is correct, and rapid A/B keyboard alternation ends in the right state. + - Hot-plug works, and key repeat generates no events. +- **No switch loop** with macOS's "Automatically switch to a document's input source". +- **macOS 13 compatibility:** every SwiftUI and ServiceManagement API used is 13.0 or older. +- **Tests:** 35 + 2 green, with zero flakes over 100 iterations and 50 iterations under CPU saturation. +- **Sparkle 2.9.6** includes the latest security fixes. +- **CI:** read-only token, no secrets, no `pull_request_target`. `brew audit --strict` and `brew style` pass on the cask. + +## Suggested order + +1. **One small PR fixing the claims:** C1 (all rows), R5 (merge the Homebrew docs), L1 (F6), M11 (log flood). That's about 30 minutes, with no behaviour change apart from two one-line fixes. +2. **Product PR, released as 1.1.0:** P1 launch at login, P2 respect manual changes, P3 onboarding completion, P4 partial HID open, M4 keyboard-only sources, M8 window activation. Add T1's two regression tests alongside. +3. **Release hardening, before cutting 1.1.0:** M2, M5, M6, M7. Protecting `main` (M2) is a GitHub setting and takes a minute. +4. **Luca, no code involved:** R2 (Search Console and Bing), M12 (Pages domain verification), R4 (repo metadata), R3 (AlternativeTo and the list PRs). +5. **Launch,** following the August plan. From d871634b77fc58f3ef3b34dbd01752453a5a6519 Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Sun, 11 Oct 2026 18:45:57 +0200 Subject: [PATCH 6/6] feat: embed release notes in the signed appcast The update dialog showed no notes, so users updating to a release that changes behaviour saw nothing about it. release.sh now embeds Scripts/release-notes/.md into the appcast, where the feed signature covers it, and adds the notes for 1.1.0. Also run the unit tests before creating dist//, so a failing test leaves nothing behind that would block the next run, and make the signature extractor print nothing rather than "None" when an item has no signature. --- Scripts/release-notes/1.1.0.md | 18 ++++++++++++++++++ Scripts/release.sh | 26 ++++++++++++++++++-------- 2 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 Scripts/release-notes/1.1.0.md diff --git a/Scripts/release-notes/1.1.0.md b/Scripts/release-notes/1.1.0.md new file mode 100644 index 0000000..0aa1360 --- /dev/null +++ b/Scripts/release-notes/1.1.0.md @@ -0,0 +1,18 @@ +## InputPilot 1.1.0 + +**After updating, the welcome window appears once** so you can turn on *Open InputPilot at login*. Without it, InputPilot stops working after a restart. + +### New +- **Layouts you pick yourself are kept.** Switch with ⌃Space, the input menu or Undo, and InputPilot keeps your choice until you type on another keyboard. Settings → Switching offers alternatives, including the old behaviour. +- **Open at login**, in the welcome window and in Settings. +- **"Use … for …"** in the menu maps the keyboard you are typing on to the current input source in one click. +- **Relaunch InputPilot** finishes setup after you grant Input Monitoring. + +### Fixed +- Two USB keyboards of the same model, on different ports, now each get their own mapping. +- Karabiner-Elements holding a keyboard no longer stops keyboard detection outright. +- Emoji & Symbols and Dictation are no longer offered as mapping targets. +- Settings and other windows now open in front of the active app. +- A lone F6 press now counts as typing. + +Full details: [CHANGELOG](https://github.com/LucaGerlich/InputPilot/blob/main/CHANGELOG.md) diff --git a/Scripts/release.sh b/Scripts/release.sh index 314917a..e7f1d3e 100755 --- a/Scripts/release.sh +++ b/Scripts/release.sh @@ -94,6 +94,13 @@ if [[ -n "$PUBLISHED_BUILD" && "$BUILD_NUMBER" -le "$PUBLISHED_BUILD" ]]; then fail "CURRENT_PROJECT_VERSION is $BUILD_NUMBER but the appcast already advertises build $PUBLISHED_BUILD. Bump it." fi +# UI tests need an unlocked desktop session and run on CI instead. Run before +# anything is created, so a failure leaves nothing to clean up. +echo "==> Running unit tests" +xcodebuild -quiet test -project InputPilot.xcodeproj -scheme InputPilot -destination 'platform=macOS' \ + -only-testing:InputPilotTests CODE_SIGN_IDENTITY=- \ + || fail "unit tests failed." + echo "Releasing $VERSION (build $BUILD_NUMBER) from $(git rev-parse --short HEAD)" mkdir -p "$RELEASE_DIR" @@ -105,12 +112,6 @@ SPARKLE_BIN="$SOURCE_PACKAGES/artifacts/sparkle/Sparkle/bin" [[ -x "$SPARKLE_BIN/generate_appcast" && -x "$SPARKLE_BIN/sign_update" ]] \ || fail "Sparkle's generate_appcast and sign_update not found in $SPARKLE_BIN." -# UI tests need an unlocked desktop session and run on CI instead. -echo "==> Running unit tests" -xcodebuild -quiet test -project InputPilot.xcodeproj -scheme InputPilot -destination 'platform=macOS' \ - -only-testing:InputPilotTests CODE_SIGN_IDENTITY=- \ - || fail "unit tests failed." - echo "==> Archiving Release build" xcodebuild -project InputPilot.xcodeproj \ -scheme InputPilot \ @@ -172,11 +173,19 @@ spctl --assess --type open --context context:primary-signature -v "$DMG" # appcast: generate_appcast keeps the earlier items as they are, URLs # included, and adds this release. It signs the feed because the new build # requires a signed one. No deltas - each would be another asset to upload. +# Release notes are embedded in the feed, so its signature covers them too. echo "==> Generating the signed Sparkle appcast" mkdir -p "$FEED_DIR" cp "$DMG" "$FEED_DIR/" cp appcast.xml "$FEED_DIR/appcast.xml" -"$SPARKLE_BIN/generate_appcast" --download-url-prefix "$DOWNLOAD_PREFIX" --maximum-deltas 0 "$FEED_DIR" +RELEASE_NOTES="Scripts/release-notes/$VERSION.md" +if [[ -f "$RELEASE_NOTES" ]]; then + cp "$RELEASE_NOTES" "$FEED_DIR/InputPilot-$VERSION.md" +else + echo "WARNING: no $RELEASE_NOTES; the update dialog will show no release notes." >&2 +fi +"$SPARKLE_BIN/generate_appcast" --download-url-prefix "$DOWNLOAD_PREFIX" --maximum-deltas 0 \ + --embed-release-notes "$FEED_DIR" cp "$FEED_DIR/appcast.xml" appcast.xml # generate_appcast only warns when it cannot sign (key missing, Keychain @@ -194,7 +203,8 @@ ns = {"sparkle": "http://www.andymatuschak.org/xml-namespaces/sparkle"} path, version = sys.argv[1], sys.argv[2] for item in ET.parse(path).getroot().iter("item"): if item.findtext("sparkle:shortVersionString", namespaces=ns) == version: - print(item.find("enclosure").get("{%s}edSignature" % ns["sparkle"])) + signature = item.find("enclosure").get("{%s}edSignature" % ns["sparkle"]) + print(signature or "") break PY )"