diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d568425 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,85 @@ +name: release + +# Build, Developer ID-sign, notarize, and publish a quill .dmg whenever a +# v* tag is pushed. Mirrors scripts/build-release.sh. Signs a menu-bar .app +# with a single Developer ID Application cert — no Installer cert needed. +# +# Required repository secrets: +# DEVELOPER_ID_APP_P12_BASE64 base64 of the Developer ID Application .p12 +# DEV_ID_P12_PASSWORD password for the .p12 +# APP_IDENTITY "Developer ID Application: NAME (TEAMID)" +# NOTARY_KEY_P8_BASE64 base64 of the App Store Connect API key (.p8) +# NOTARY_KEY_ID App Store Connect key id +# NOTARY_ISSUER_ID App Store Connect issuer id + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + version: + description: "Version to build (without leading v)" + required: true + +permissions: + contents: write + +jobs: + release: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: Resolve version + id: v + run: | + if [ -n "${{ github.event.inputs.version }}" ]; then + echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT" + else + echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + fi + + - name: Import signing certificate + env: + APP_P12: ${{ secrets.DEVELOPER_ID_APP_P12_BASE64 }} + P12_PASSWORD: ${{ secrets.DEV_ID_P12_PASSWORD }} + run: | + KEYCHAIN="$RUNNER_TEMP/build.keychain" + KEYCHAIN_PW="$(uuidgen)" + security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" + + echo "$APP_P12" | base64 --decode > "$RUNNER_TEMP/app.p12" + security import "$RUNNER_TEMP/app.p12" -k "$KEYCHAIN" \ + -P "$P12_PASSWORD" -T /usr/bin/codesign + rm -f "$RUNNER_TEMP/app.p12" + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PW" "$KEYCHAIN" + security list-keychains -d user -s "$KEYCHAIN" login.keychain + + - name: Store notary credentials + env: + NOTARY_KEY_P8: ${{ secrets.NOTARY_KEY_P8_BASE64 }} + NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.NOTARY_ISSUER_ID }} + run: | + echo "$NOTARY_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8" + xcrun notarytool store-credentials quill-notary \ + --key "$RUNNER_TEMP/notary.p8" \ + --key-id "$NOTARY_KEY_ID" \ + --issuer "$NOTARY_ISSUER_ID" + rm -f "$RUNNER_TEMP/notary.p8" + + - name: Build, sign, notarize + env: + VERSION: ${{ steps.v.outputs.version }} + APP_IDENTITY: ${{ secrets.APP_IDENTITY }} + NOTARY_PROFILE: quill-notary + run: ./scripts/build-release.sh + + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + files: dist/quill-${{ steps.v.outputs.version }}.dmg + generate_release_notes: true diff --git a/.gitignore b/.gitignore index e94f7ce..15039b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .build/ .DS_Store +dist/ diff --git a/README.md b/README.md index 13488d4..d6e6635 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,25 @@ Swift binary, menu-bar tray, no app bundle. ## Install +**Download (recommended).** Grab the latest `.dmg` from the +[Releases](https://github.com/digimata/quill/releases) page, open it, and drag +**quill.app** to Applications. Launch it and the feather appears in the menu +bar. + +The app is signed with a Developer ID and notarized by Apple, so it opens +without Gatekeeper warnings. + +To also use the CLI (`quill doctor`, `quill run`, …), symlink the bundled +binary onto your `PATH`: + +```sh +sudo ln -sf /Applications/quill.app/Contents/MacOS/quill /usr/local/bin/quill +quill doctor # check permissions & models +quill install --launch-at-login # optional — run in the background on login +``` + +**Build from source.** + ```sh cd quill swift build -c release @@ -17,9 +36,13 @@ sudo cp .build/release/quill /usr/local/bin/quill quill install --launch-at-login # optional — runs in the background on login ``` +To produce a signed, notarized `.dmg` yourself, see +[`scripts/build-release.sh`](scripts/build-release.sh) and +[Releasing](#releasing). + **Requires:** macOS 15+ (Core Audio process taps for system audio — no -virtual device, no kernel extension). Apple Silicon recommended for -transcription speed. +virtual device, no kernel extension). Apple Silicon (the release binary is +`arm64`; build from source for Intel). ## How to use @@ -114,6 +137,35 @@ quill install --uninstall - **FluidAudio / Parakeet** — on-device Core ML transcription - **NSStatusItem** — the whole UI +## Releasing + +Distribution artifacts are Developer ID-signed and Apple-notarized so they run +without Gatekeeper prompts. quill ships as a menu-bar `quill.app` inside a +`.dmg`; everything signs with a single **Developer ID Application** +certificate — no Developer ID Installer cert required. + +- **Locally:** `scripts/build-release.sh` builds the release binary, wraps it + in `quill.app` (Info.plist from `packaging/Info.plist`), codesigns it + (hardened runtime + `packaging/quill.entitlements`), notarizes and staples + the app, then packages and notarizes a `.dmg`. It reads its config from the + environment: + + ```sh + APP_IDENTITY="Developer ID Application: NAME (TEAMID)" \ + NOTARY_PROFILE=quill-notary \ + VERSION=0.1.0 \ + ./scripts/build-release.sh + ``` + + `NOTARY_PROFILE` is a `notarytool` keychain profile + (`xcrun notarytool store-credentials`). Set `SKIP_NOTARIZE=1` to sign without + notarizing (local testing only). + +- **CI:** push a `v*` tag and `.github/workflows/release.yml` builds, signs, + notarizes, and attaches the `.dmg` to the GitHub release. It expects the + certificate and App Store Connect notary secrets documented at the top of + that workflow. + ## Gotchas - A global tap records *everything* the Mac plays — notification dings, diff --git a/packaging/Info.plist b/packaging/Info.plist new file mode 100644 index 0000000..2648f5a --- /dev/null +++ b/packaging/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleIdentifier + com.digimata.quill + CFBundleName + quill + CFBundleDisplayName + quill + CFBundleExecutable + quill + CFBundleIconFile + quill + CFBundlePackageType + APPL + CFBundleShortVersionString + @VERSION@ + CFBundleVersion + @VERSION@ + LSMinimumSystemVersion + 15.0 + LSUIElement + + NSMicrophoneUsageDescription + quill records your microphone during meetings so you can transcribe them later. Audio never leaves this Mac. + NSAudioCaptureUsageDescription + quill records system audio (the other side of your meetings) so you can transcribe them later. Audio never leaves this Mac. + + diff --git a/packaging/icon.svg b/packaging/icon.svg new file mode 100644 index 0000000..13368ad --- /dev/null +++ b/packaging/icon.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packaging/quill.entitlements b/packaging/quill.entitlements new file mode 100644 index 0000000..d459cb2 --- /dev/null +++ b/packaging/quill.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.device.audio-input + + + diff --git a/packaging/quill.icns b/packaging/quill.icns new file mode 100644 index 0000000..d469b03 Binary files /dev/null and b/packaging/quill.icns differ diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..da9a938 --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# +# Build, sign, notarize, and package quill for distribution as a menu-bar .app +# shipped inside a .dmg. Everything signs with a single Developer ID +# Application certificate — no Developer ID Installer cert required. +# +# The same script runs locally and in CI. +# +# Configuration (all via environment, with local-dev defaults): +# VERSION release version, e.g. 0.1.0 (default: 0.1.0) +# APP_IDENTITY "Developer ID Application: ..." name / hash +# NOTARY_PROFILE notarytool keychain profile name (enables notarize+staple) +# SKIP_NOTARIZE=1 build & sign but don't notarize +# +# Requirements: Xcode toolchain and a Developer ID Application cert. Notarization +# needs stored credentials (`xcrun notarytool store-credentials`). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +VERSION="${VERSION:-0.1.0}" +APP_IDENTITY="${APP_IDENTITY:-Developer ID Application: Omer Karisman (XH64JAYUW5)}" +NOTARY_PROFILE="${NOTARY_PROFILE:-}" +BUNDLE_ID="com.digimata.quill" + +BUILD_BIN="$ROOT/.build/release/quill" +ENTITLEMENTS="$ROOT/packaging/quill.entitlements" +PLIST_TEMPLATE="$ROOT/packaging/Info.plist" +DIST="$ROOT/dist" +APP="$DIST/quill.app" +DMG="$DIST/quill-$VERSION.dmg" +DMG_STAGE="$DIST/dmg" + +step() { printf '\n\033[1;34m==>\033[0m %s\n' "$1"; } + +step "Building release binary (arm64)" +swift build -c release + +step "Assembling quill.app" +rm -rf "$APP" "$DMG" "$DMG_STAGE" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +sed "s/@VERSION@/$VERSION/g" "$PLIST_TEMPLATE" > "$APP/Contents/Info.plist" +cp "$BUILD_BIN" "$APP/Contents/MacOS/quill" +cp "$ROOT/packaging/quill.icns" "$APP/Contents/Resources/quill.icns" +xattr -cr "$APP" + +step "Codesigning app (hardened runtime + entitlements)" +codesign --force --options runtime --timestamp \ + --entitlements "$ENTITLEMENTS" \ + --sign "$APP_IDENTITY" \ + "$APP" +codesign --verify --strict --verbose=2 "$APP" + +if [[ "${SKIP_NOTARIZE:-}" != "1" && -n "$NOTARY_PROFILE" ]]; then + step "Notarizing app" + ditto -c -k --keepParent "$APP" "$DIST/quill-app.zip" + xcrun notarytool submit "$DIST/quill-app.zip" --keychain-profile "$NOTARY_PROFILE" --wait + rm -f "$DIST/quill-app.zip" + step "Stapling app" + xcrun stapler staple "$APP" +else + step "Skipping app notarization (SKIP_NOTARIZE set or NOTARY_PROFILE empty)" +fi + +step "Building .dmg" +mkdir -p "$DMG_STAGE" +cp -R "$APP" "$DMG_STAGE/" +ln -s /Applications "$DMG_STAGE/Applications" +hdiutil create -volname "quill $VERSION" -srcfolder "$DMG_STAGE" \ + -ov -format UDZO "$DMG" >/dev/null +rm -rf "$DMG_STAGE" + +step "Codesigning .dmg" +codesign --force --timestamp --sign "$APP_IDENTITY" "$DMG" + +if [[ "${SKIP_NOTARIZE:-}" != "1" && -n "$NOTARY_PROFILE" ]]; then + step "Notarizing .dmg" + xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait + step "Stapling .dmg" + xcrun stapler staple "$DMG" + xcrun stapler validate "$DMG" + spctl --assess --type open --context context:primary-signature --verbose=4 "$DMG" || true +else + step "Skipping .dmg notarization" + echo "WARNING: unsigned-of-notarization build — for local testing only." +fi + +step "Done" +echo "App: $APP" +echo "Artifact: $DMG"