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.