Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ xcuserdata/
dist/
*.dmg

# Local Claude Code settings
.claude/settings.local.json

# Test artifacts
.playwright-mcp/
landing-*.png
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 9 additions & 0 deletions Config/AppInfo.plist
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@
is invalid and makes Sparkle abort with a fatal updater error. -->
<key>SUPublicEDKey</key>
<string>pssjO3kU2/CHGn3oOa4atzbvmFbwind07K8GZIuqOpY=</string>
<!-- Updates are verified before extraction, and the appcast and release
notes themselves must carry a valid EdDSA signature (Sparkle 2.9+), so
push access to the repo alone cannot change what users are offered.
From here on every appcast must be signed: release.sh does it, and
appcast.xml must never be edited by hand. -->
<key>SUVerifyUpdateBeforeExtraction</key>
<true/>
<key>SURequireSignedFeed</key>
<true/>
<!-- Set explicitly so Sparkle skips its first-launch permission dialog. -->
<key>SUEnableAutomaticChecks</key>
<true/>
Expand Down
2 changes: 0 additions & 2 deletions InputPilot.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down
18 changes: 18 additions & 0 deletions Scripts/release-notes/1.1.0.md
Original file line number Diff line number Diff line change
@@ -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)
217 changes: 141 additions & 76 deletions Scripts/release.sh
Original file line number Diff line number Diff line change
@@ -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 <version> (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 <version> (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 <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 <file>`: every future appcast needs it.

set -euo pipefail

VERSION="${1:-}"
if [[ -z "$VERSION" ]]; then
echo "Usage: Scripts/release.sh <version> (e.g. 1.0.0)" >&2
exit 1
echo "Usage: Scripts/release.sh <version> (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 "<string></string>"; 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 "<string>[^<]+</string>" \
|| 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 '<sparkle:version>[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 '<sparkle:version>[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"
Expand All @@ -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"
Expand All @@ -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)"
Expand All @@ -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 <changelog section>"
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."
Loading
Loading