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
39 changes: 39 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ jobs:
name: macOS Universal (fat binary)
needs: build-macos
runs-on: macos-15
# The signing secrets live in the "ENV" environment, so this job must opt
# into it to read them.
environment: ENV
# Surface signing secrets as env so steps can gate on them (the `secrets`
# context is not allowed in `if:`). Empty when unconfigured → ad-hoc path.
env:
MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
steps:
- uses: actions/checkout@v4

Expand Down Expand Up @@ -102,9 +114,26 @@ jobs:
target/x86_64-apple-darwin/release/holler \
-output target/universal-apple-darwin/release/holler

# Import the Developer ID cert into a throwaway keychain so codesign can
# find it. Skipped (→ ad-hoc signing) until the secret is configured.
- name: Import signing certificate
if: env.MACOS_CERT_P12_BASE64 != ''
run: |
KEYCHAIN="$RUNNER_TEMP/build.keychain-db"
KEYCHAIN_PW="$(openssl rand -base64 24)"
security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN"
echo "$MACOS_CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$MACOS_CERT_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PW" "$KEYCHAIN"
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | sed s/\"//g)

- name: Bundle universal .app
env:
BINARY_PATH: target/universal-apple-darwin/release/holler
# Empty when unconfigured → bundle-macos.sh falls back to ad-hoc.
SIGN_IDENTITY: ${{ env.MACOS_SIGN_IDENTITY }}
run: |
if [ "$GITHUB_REF_TYPE" = "tag" ]; then export VERSION="${GITHUB_REF_NAME#v}"; fi
bash scripts/bundle-macos.sh
Expand All @@ -116,6 +145,16 @@ jobs:
-ov -format UDZO \
Holler-macOS-universal.dmg

# Notarize the DMG with Apple and staple the ticket so it installs without
# a Gatekeeper warning, even offline. Skipped until secrets are configured.
- name: Notarize & staple
if: env.APPLE_ID != ''
run: |
xcrun notarytool submit Holler-macOS-universal.dmg \
--apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_PASSWORD" --wait
xcrun stapler staple Holler-macOS-universal.dmg

- name: Upload universal DMG
uses: actions/upload-artifact@v4
with:
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,10 @@ cargo run # run from the terminal (see logs); injection/keychain
For logs while using the bundle’s stable identity, run the inner binary:
`./Holler.app/Contents/MacOS/holler`.

> Note: ad-hoc signing ties permissions to the exact binary, so after
> rebuilding the bundle you may need to re-approve Accessibility. A Developer ID
> signature (Phase 3) makes the grant permanent.
> Note: local/ad-hoc signing ties permissions to the exact binary, so after
> rebuilding you may need to re-approve Accessibility. Release DMGs are signed
> with a **Developer ID** and notarized once the signing secrets are configured
> (see [`docs/SIGNING.md`](docs/SIGNING.md)), which also makes the grant stick.

### Troubleshooting

Expand Down
2 changes: 1 addition & 1 deletion docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Captured during the planning session on **2026-06-08** with Yassir. These are **
| Copy memory | Clipboard set **and** searchable SQLite history | Yassir chose "Both". |
| AI providers | Provider-agnostic/BYOK behind traits — Claude, OpenAI, Deepgram, local OpenAI-compatible (Ollama) | Flexibility. **Key storage REVISED 2026-06-10:** moved from the OS keychain (`keyring`) to a `0600` `secrets.toml` in the config dir (separate from `config.toml`). Ad-hoc-signed macOS bundles change identity each rebuild, so the keychain TCC grant never stuck and macOS re-prompted on every run. `HOLLER_<PROVIDER>_KEY` env var overrides the file. |
| TTS | Deferred to phase 3 | De-risk v1; design traits now, build later. |
| Distribution | Public eventually → plan signing/notarization + permission onboarding from the start | Stable Developer ID; clean macOS TCC flow. |
| Distribution | Public eventually → plan signing/notarization + permission onboarding from the start | Stable Developer ID; clean macOS TCC flow. **2026-06-10:** repo now public; Developer ID signing + notarization wired into CI (gated on secrets, ad-hoc fallback) — see `docs/SIGNING.md`. |

## Recommendations I made on Yassir's behalf (open to challenge)
- **Defer GUI to phase 2**, ship Phase 1 with a TOML config file. Removes the tray+hotkey+egui main-thread-loop integration risk from the MVP critical path.
Expand Down
67 changes: 67 additions & 0 deletions docs/SIGNING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# macOS code signing & notarization

The release pipeline signs the universal macOS DMG with a **Developer ID**
certificate, notarizes it with Apple, and staples the ticket — so it installs
without a Gatekeeper warning on any Mac, and the Accessibility/Microphone grants
survive app rebuilds (a stable signing identity). Until the secrets below are
set, CI falls back to **ad-hoc** signing (Gatekeeper still warns).

## One-time setup

### 1. Create a "Developer ID Application" certificate
Xcode → Settings → Accounts → your Apple ID → **Manage Certificates** → + →
**Developer ID Application**. (Requires the paid Apple Developer Program.)

Find its full identity string and your Team ID:
```bash
security find-identity -v -p codesigning
# → "Developer ID Application: Your Name (ABCDE12345)" ← TEAMID is the (...)
```

### 2. Export the cert as a `.p12` and base64 it
Keychain Access → My Certificates → right-click the *Developer ID Application*
cert → **Export** → `.p12` (set an export password). Then:
```bash
base64 -i Certificate.p12 | pbcopy # base64 now on the clipboard
```

### 3. Create an app-specific password
[appleid.apple.com](https://appleid.apple.com) → Sign-In & Security →
**App-Specific Passwords** → generate one for "Holler notarization".

### 4. Add these GitHub repo Secrets
Repo → Settings → Secrets and variables → **Actions** → New repository secret:

| Secret | Value |
|---|---|
| `MACOS_CERT_P12_BASE64` | the base64 string from step 2 |
| `MACOS_CERT_PASSWORD` | the `.p12` export password |
| `MACOS_SIGN_IDENTITY` | `Developer ID Application: Your Name (TEAMID)` (exact string from step 1) |
| `APPLE_ID` | your Apple ID email |
| `APPLE_TEAM_ID` | the Team ID, e.g. `ABCDE12345` |
| `APPLE_APP_PASSWORD` | the app-specific password from step 3 |

> Never put these in the repo or paste them in chat — repo Secrets only.

### 5. Release
Tag as usual — the next release DMG is signed, notarized, and stapled:
```bash
git tag -a v0.1.1 -m "..." && git push origin v0.1.1
```
Verify a downloaded DMG would pass Gatekeeper on a clean Mac:
```bash
spctl -a -t open --context context:primary-signature -v Holler-macOS-universal.dmg # → accepted
```

## Signing locally (optional)
On your own Mac with the cert in your keychain:
```bash
SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bash scripts/bundle-macos.sh
```
Notarization then mirrors the CI step (`xcrun notarytool submit … --wait` →
`xcrun stapler staple …`). Entitlements live in `scripts/holler.entitlements`
(hardened runtime needs `com.apple.security.device.audio-input` for the mic).

## Windows
Windows signing (an OV/EV Authenticode cert to avoid SmartScreen) is a separate,
still-deferred task — the current Windows ZIP is unsigned.
21 changes: 18 additions & 3 deletions scripts/bundle-macos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,24 @@ cat > "$APP_DIR/Contents/Info.plist" <<PLIST
</plist>
PLIST

echo "==> Ad-hoc code signing"
codesign --force --sign - --timestamp=none "$APP_DIR/Contents/MacOS/holler"
codesign --force --sign - --timestamp=none "$APP_DIR"
# Sign with a real Developer ID when SIGN_IDENTITY is set (CI/release), so the
# app passes Gatekeeper for everyone and the TCC grant survives rebuilds.
# Otherwise fall back to ad-hoc for local dev (Gatekeeper will warn).
ENTITLEMENTS="$(dirname "$0")/holler.entitlements"
if [[ -n "${SIGN_IDENTITY:-}" ]]; then
echo "==> Developer ID signing + hardened runtime ($SIGN_IDENTITY)"
# Entitlements + hardened runtime go on the executable; the bundle wrapper is
# then sealed over it. A secure --timestamp is required for notarization.
codesign --force --options runtime --timestamp \
--entitlements "$ENTITLEMENTS" \
--sign "$SIGN_IDENTITY" "$APP_DIR/Contents/MacOS/holler"
codesign --force --options runtime --timestamp \
--sign "$SIGN_IDENTITY" "$APP_DIR"
else
echo "==> Ad-hoc code signing (no SIGN_IDENTITY — Gatekeeper will warn on other Macs)"
codesign --force --sign - --timestamp=none "$APP_DIR/Contents/MacOS/holler"
codesign --force --sign - --timestamp=none "$APP_DIR"
fi
codesign --verify --verbose "$APP_DIR"

echo
Expand Down
11 changes: 11 additions & 0 deletions scripts/holler.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?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">
<plist version="1.0">
<dict>
<!-- Hardened Runtime is required for notarization, and it gates mic access
behind this entitlement — without it cpal capture silently fails on a
notarized build even with NSMicrophoneUsageDescription present. -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
Loading