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
79 changes: 79 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: Release

# Cut a signed + notarized DMG and publish it to a GitHub Release when a
# v* tag is pushed, e.g.: git tag v0.2.0 && git push origin v0.2.0
on:
push:
tags: ["v*"]

permissions:
contents: write # required to create the Release and upload assets

jobs:
release:
name: Sign, notarize & publish
# Dormant until you opt in: set repository variable RELEASE_ENABLED=true
# (Settings → Secrets and variables → Actions → Variables) once the signing
# secrets are in place. Until then a v* tag push is a no-op.
if: vars.RELEASE_ENABLED == 'true'
runs-on: macos-15
defaults:
run:
working-directory: driver
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # tags + full history for version/build stamping

# --- Import the Developer ID cert into an ephemeral keychain ------------
- name: Import signing certificate
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
KEYCHAIN="$RUNNER_TEMP/build.keychain-db"
echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12"

security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \
-P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
# Make it the default so codesign finds the identity.
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
rm -f "$RUNNER_TEMP/cert.p12"

# --- Write the App Store Connect API key for notarytool ----------------
- name: Write notarization API key
env:
NOTARY_KEY: ${{ secrets.NOTARY_KEY }}
run: echo "$NOTARY_KEY" | base64 --decode > "$RUNNER_TEMP/notary_key.p8"

- name: Build app bundle
run: scripts/build-app.sh build

- name: Sign, notarize & package DMG
env:
SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
NOTARY_KEY_PATH: ${{ runner.temp }}/notary_key.p8
NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }}
NOTARY_ISSUER: ${{ secrets.NOTARY_ISSUER }}
run: scripts/release.sh build

- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" \
--title "$GITHUB_REF_NAME" \
--generate-notes \
build/Nib-*.dmg

- name: Clean up keychain & key
if: always()
run: |
security delete-keychain "$RUNNER_TEMP/build.keychain-db" 2>/dev/null || true
rm -f "$RUNNER_TEMP/notary_key.p8"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ driver/.build/
*.o
*.swiftmodule

# Release artifacts (signed app bundle + DMG)
driver/build/

# Compiled RE tools (source is tracked; rebuild with clang)
captures/hidinfo

Expand Down
7 changes: 4 additions & 3 deletions driver/INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ rm ~/Library/LaunchAgents/com.modfox.nib.plist
rm -rf ~/Applications/Nib.app
```

## TODO for a distributable build
- Sign with a Developer ID identity + notarize so the cdhash is stable and grants persist
across updates (ad-hoc re-signing currently resets TCC).
## Distributable builds
The steps above use the self-signed `modfox-codesign` identity, which is fine for a
single machine. To produce a signed + notarized `.dmg` for GitHub Releases (stable
cdhash, so grants persist across updates), see **[RELEASING.md](RELEASING.md)**.
126 changes: 126 additions & 0 deletions driver/RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Releasing a signed, notarized build

Distributable builds are **signed with a Developer ID Application certificate and
notarized by Apple**, then published as a `.dmg` on GitHub Releases. Notarization
is what lets other people download `Nib.app` and open it without Gatekeeper
warnings — and, because the code hash is stable across notarized updates, it keeps
the Input Monitoring + Accessibility (TCC) grants from resetting on every update
(the problem called out in `INSTALL.md`).

This requires an **Apple Developer Program** membership ($99/yr).

---

## One-time setup

### 1. Create the Developer ID Application certificate

In Xcode: **Settings → Accounts → (your team) → Manage Certificates → `+` →
Developer ID Application**. (Or create it at
<https://developer.apple.com/account/resources/certificates>.)

Confirm it's installed and note the exact identity string:

```
security find-identity -v -p codesigning
# -> "Developer ID Application: Your Name (TEAMID)"
```

That full string (including the `(TEAMID)`) is your **`SIGNING_IDENTITY`**.

### 2. Create an App Store Connect API key (for notarization)

`notarytool` authenticates with an API key instead of your Apple ID + password —
better for CI (no 2FA prompts). At
<https://appstoreconnect.apple.com/access/integrations/api> → **Keys** → `+`:

- Access role: **Developer** is sufficient for notarization.
- Download the `AuthKey_XXXXXXXX.p8` (**you can only download it once**).
- Note the **Key ID** and the **Issuer ID** shown on that page.

### 3. Verify the whole pipeline locally (recommended before wiring CI)

Store the notarization credentials as a reusable keychain profile:

```
xcrun notarytool store-credentials nib-notary \
--key /path/to/AuthKey_XXXXXXXX.p8 \
--key-id <KEY_ID> \
--issuer <ISSUER_ID>
```

Then build → sign → notarize → staple in one go:

```
cd driver
scripts/build-app.sh build
SIGNING_IDENTITY="Developer ID Application: Your Name (TEAMID)" \
NOTARY_PROFILE=nib-notary \
scripts/release.sh build
# -> build/Nib-<version>.dmg (signed, notarized, stapled)
```

A green run ends with `The staple and validate action worked!` and a Gatekeeper
`accepted` line. That same DMG is exactly what CI produces.

---

## CI setup (GitHub Actions)

The `.github/workflows/release.yml` workflow runs on any `v*` tag: it imports the
cert into an ephemeral keychain, builds the universal app, signs + notarizes +
staples the DMG, and publishes it to a GitHub Release.

> **The workflow is dormant by default.** Its job is gated on
> `if: vars.RELEASE_ENABLED == 'true'`, so a `v*` tag push does nothing until you
> opt in. When you're ready, set a repository **variable** `RELEASE_ENABLED` to
> `true` (Settings → Secrets and variables → Actions → **Variables**). Removing the
> variable (or setting it to anything else) disables releases again.

Add these under **Settings → Secrets and variables → Actions → New repository
secret**:

| Secret | How to produce it |
| -------------------------- | ----------------- |
| `MACOS_CERTIFICATE` | Export the Developer ID cert **with its private key** from Keychain Access as a `.p12`, then `base64 -i cert.p12 \| pbcopy`. |
| `MACOS_CERTIFICATE_PWD` | The password you set when exporting the `.p12`. |
| `MACOS_SIGNING_IDENTITY` | The full `Developer ID Application: Your Name (TEAMID)` string. |
| `KEYCHAIN_PASSWORD` | Any random string — names the throwaway CI keychain. |
| `NOTARY_KEY` | `base64 -i AuthKey_XXXXXXXX.p8 \| pbcopy`. |
| `NOTARY_KEY_ID` | The API key's Key ID. |
| `NOTARY_ISSUER` | The App Store Connect Issuer ID. |

> Exporting the `.p12`: in **Keychain Access**, expand the certificate so its
> private key shows, select **both** rows, right-click → *Export 2 items…* →
> Personal Information Exchange (.p12).

### Cut a release

```
git tag v0.2.0
git push origin v0.2.0
```

The workflow builds, notarizes, and attaches `Nib-0.2.0.dmg` to a new
`v0.2.0` Release with auto-generated notes. The version string is derived from
the tag (`v0.2.0` → `0.2.0`), so no need to hand-edit `Info.plist`.

---

## How versioning works

`scripts/build-app.sh` stamps the bundle from git at build time and never mutates
the committed `packaging/Info.plist`:

- `CFBundleShortVersionString` ← latest tag with the `v` stripped (`VERSION` env overrides).
- `CFBundleVersion` ← commit count (`BUILD_NUM` env overrides).

## Notes

- The app is signed with the **Hardened Runtime** (required for notarization) and
an empty `packaging/entitlements.plist` — Nib needs no special entitlements
(HID + CGEvent access is granted at runtime via TCC, not entitlements) and must
**not** be sandboxed, which would block raw HID access.
- Local dev installs (`INSTALL.md`) still use the self-signed `modfox-codesign`
identity — that's fine for a single machine. Developer ID + notarization is only
needed for the artifacts you hand to other people.
15 changes: 15 additions & 0 deletions driver/packaging/entitlements.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
Hardened Runtime entitlements for the notarized Developer ID build.

Intentionally empty: Nib reads pen HID via IOHIDManager (IOKit) and posts
cursor/pressure events via CGEvent. Under the Hardened Runtime neither needs
an entitlement — both are gated by TCC (Input Monitoring + Accessibility),
which the user grants at runtime. We do NOT enable the App Sandbox, which
would block raw HID device access.
-->
<plist version="1.0">
<dict>
</dict>
</plist>
39 changes: 39 additions & 0 deletions driver/scripts/build-app.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
#
# Build the release binary and assemble an *unsigned* Nib.app bundle.
# Shared by local dev and the release workflow; signing is done separately
# (scripts/release.sh) so this step needs no certificates.
#
# Usage: scripts/build-app.sh [OUTPUT_DIR] (default OUTPUT_DIR=build)
# Env:
# VERSION CFBundleShortVersionString (default: latest git tag, sans "v")
# BUILD_NUM CFBundleVersion (default: git commit count)
#
set -euo pipefail

cd "$(dirname "$0")/.." # -> driver/

OUT="${1:-build}"
APP="$OUT/Nib.app"

VERSION="${VERSION:-$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo 0.0.0)}"
BUILD_NUM="${BUILD_NUM:-$(git rev-list --count HEAD 2>/dev/null || echo 1)}"

echo "==> Building Nib.app (version=$VERSION build=$BUILD_NUM)"

# Universal binary so the DMG runs on both Apple Silicon and Intel.
ARCHS=(--arch arm64 --arch x86_64)
swift build -c release "${ARCHS[@]}"
BIN="$(swift build -c release "${ARCHS[@]}" --show-bin-path)/nib"

rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp "$BIN" "$APP/Contents/MacOS/nib"
cp packaging/Info.plist "$APP/Contents/Info.plist"
cp packaging/Nib.icns "$APP/Contents/Resources/Nib.icns"

# Stamp the version into the copied plist (never mutate the committed template).
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$APP/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUM" "$APP/Contents/Info.plist"

echo "==> Assembled $APP"
75 changes: 75 additions & 0 deletions driver/scripts/release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
#
# Sign (Developer ID + Hardened Runtime), package as a DMG, notarize, and staple.
# Produces a notarized build/Nib-<version>.dmg ready to attach to a GitHub Release.
#
# Assumes scripts/build-app.sh has already produced build/Nib.app.
#
# Required env:
# SIGNING_IDENTITY "Developer ID Application: Your Name (TEAMID)"
#
# Notarization credentials — provide ONE of:
# NOTARY_PROFILE name of a stored `notarytool store-credentials` profile (local)
# -- or -- (CI)
# NOTARY_KEY_PATH path to the App Store Connect API key .p8
# NOTARY_KEY_ID the key's Key ID
# NOTARY_ISSUER the App Store Connect issuer UUID
#
set -euo pipefail

cd "$(dirname "$0")/.." # -> driver/

OUT="${1:-build}"
APP="$OUT/Nib.app"
[[ -d "$APP" ]] || { echo "error: $APP not found — run scripts/build-app.sh first" >&2; exit 1; }

VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")"
DMG="$OUT/Nib-$VERSION.dmg"

: "${SIGNING_IDENTITY:?set SIGNING_IDENTITY to your Developer ID Application identity}"

# --- notarytool credential args (profile locally, API key in CI) -------------
notary_args=()
if [[ -n "${NOTARY_PROFILE:-}" ]]; then
notary_args=(--keychain-profile "$NOTARY_PROFILE")
else
: "${NOTARY_KEY_PATH:?set NOTARY_PROFILE or the NOTARY_KEY_* trio}"
: "${NOTARY_KEY_ID:?}"
: "${NOTARY_ISSUER:?}"
notary_args=(--key "$NOTARY_KEY_PATH" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER")
fi

echo "==> Codesign app (Hardened Runtime, secure timestamp)"
codesign --force --options runtime --timestamp \
--entitlements packaging/entitlements.plist \
--sign "$SIGNING_IDENTITY" \
"$APP"
codesign --verify --strict --verbose=2 "$APP"

echo "==> Notarize the app, then staple the ticket into the bundle"
# Staple the .app itself so it launches offline once copied out of the DMG.
APP_ZIP="$OUT/Nib.app.zip"
ditto -c -k --keepParent "$APP" "$APP_ZIP"
xcrun notarytool submit "$APP_ZIP" "${notary_args[@]}" --wait
xcrun stapler staple "$APP"
rm -f "$APP_ZIP"

echo "==> Build DMG from the stapled app"
STAGE="$(mktemp -d)"
cp -R "$APP" "$STAGE/Nib.app"
ln -s /Applications "$STAGE/Applications" # drag-to-install affordance
rm -f "$DMG"
hdiutil create -volname "Nib" -srcfolder "$STAGE" -ov -format UDZO "$DMG"
rm -rf "$STAGE"

echo "==> Sign, notarize, and staple the DMG"
codesign --force --timestamp --sign "$SIGNING_IDENTITY" "$DMG"
xcrun notarytool submit "$DMG" "${notary_args[@]}" --wait
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"

echo "==> Gatekeeper assessment"
spctl -a -t open --context context:primary-signature -v "$DMG" || true

echo ""
echo "Done: $DMG"
Loading