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: |
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/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
diff --git a/InputPilot.xcodeproj/project.pbxproj b/InputPilot.xcodeproj/project.pbxproj
index d99ad58..98d381b 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";
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 e528dc0..e7f1d3e 100755
--- a/Scripts/release.sh
+++ b/Scripts/release.sh
@@ -1,107 +1,143 @@
#!/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)"
+# 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."
-rm -rf "$DIST"
-mkdir -p "$DIST"
+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."
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 +147,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 +156,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 +169,59 @@ 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"
+# 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.
+# 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"
+RELEASE_NOTES="Scripts/release-notes/$VERSION.md"
+if [[ -f "$RELEASE_NOTES" ]]; then
+ cp "$RELEASE_NOTES" "$FEED_DIR/InputPilot-$VERSION.md"
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
+ 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
+# 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:
+ signature = item.find("enclosure").get("{%s}edSignature" % ns["sparkle"])
+ print(signature or "")
+ 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."
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.