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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,15 @@ jobs:
steps:
- uses: actions/checkout@v6

- name: build stack-nudge.app
- name: build StackNudge.app
run: ./build.sh ${{ matrix.arch }}

- name: verify executable exists + is correct arch
run: |
set -e
bin=$(ls build/stack-nudge.app/Contents/MacOS/)
file "build/stack-nudge.app/Contents/MacOS/$bin"
file "build/stack-nudge.app/Contents/MacOS/$bin" | grep -q "${{ matrix.arch }}"
bin=$(ls build/StackNudge.app/Contents/MacOS/)
file "build/StackNudge.app/Contents/MacOS/$bin"
file "build/StackNudge.app/Contents/MacOS/$bin" | grep -q "${{ matrix.arch }}"

- name: verify Info.plist is valid
run: plutil -lint panel/Info.plist
Expand Down
152 changes: 113 additions & 39 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,70 +1,144 @@
name: Build and Release

# Triggered when release-please pushes a vX.Y.Z tag. Produces two
# fully-signed and notarized .tar.gz artifacts (arm64 + x86_64) plus
# sha256 sidecars, attached to the release that release-please created.
#
# Per-arch artifacts (rather than a universal binary) because the
# bundled Python venv is arch-specific — its native extensions (.so /
# .dylib) only carry one architecture, and a universal2 Python is more
# trouble than two separate artifacts.

on:
push:
tags:
- 'v*'

jobs:
release:
name: Build universal binary, attach to release
name: Build, sign, notarize (${{ matrix.arch }})
runs-on: macos-15
permissions:
contents: write
strategy:
matrix:
arch: [arm64, x86_64]
fail-fast: false # let one arch's failure not block the other

env:
# Tells build.sh to bundle the stackvox Python venv into
# Resources/venv/. Local dev iteration leaves this unset for speed.
STACKNUDGE_BUNDLE_VENV: "1"
KEYCHAIN: build-stack-nudge.keychain

steps:
- uses: actions/checkout@v6

# Stamp the tag's version into Info.plist so the bundled app advertises
# the right version regardless of what's checked into main.
# Stamp the tag's version into Info.plist so the bundled app
# advertises the right version regardless of what's checked into
# main. The release-please extra-files mechanism keeps main in
# sync too, but stamping at build time is belt-and-braces.
- name: Stamp version from tag
run: |
VERSION="${GITHUB_REF_NAME#v}"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" panel/Info.plist
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" panel/Info.plist

# Build each arch sequentially via swiftc's -target flag (cross-compiles
# fine on macos-15 runners). Stash the per-arch mach-o so we can lipo
# them into a universal binary afterward.
- name: Build arm64
run: bash build.sh arm64
- name: Stash arm64 binary
run: cp build/stack-nudge.app/Contents/MacOS/stack-nudge /tmp/stack-nudge-arm64
# Decode the Developer ID Application cert from secrets, import
# it into a temporary keychain, unlock it, set the partition list
# so codesign can use the private key without prompts. Stores the
# resolved identity in $GITHUB_ENV so the build step picks it up
# via build.sh's $STACKNUDGE_SIGN_IDENTITY override path.
- name: Set up signing identity
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
MACOS_KEYCHAIN_PWD: ${{ secrets.MACOS_KEYCHAIN_PWD }}
run: |
set -euo pipefail
echo "$MACOS_CERTIFICATE" | base64 --decode > /tmp/cert.p12
security create-keychain -p "$MACOS_KEYCHAIN_PWD" "$KEYCHAIN"
security default-keychain -s "$KEYCHAIN"
security unlock-keychain -p "$MACOS_KEYCHAIN_PWD" "$KEYCHAIN"
# Keep the keychain unlocked for the whole job — codesign on
# bundled venv files runs many times and a re-lock mid-build
# would force every invocation through a system prompt.
security set-keychain-settings -lut 7200 "$KEYCHAIN"
security import /tmp/cert.p12 -k "$KEYCHAIN" \
-P "$MACOS_CERTIFICATE_PWD" \
-T /usr/bin/codesign \
-T /usr/bin/security
# Grant codesign + apple tooling access without further prompts.
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s -k "$MACOS_KEYCHAIN_PWD" "$KEYCHAIN"
IDENTITY=$(
security find-identity -v -p codesigning "$KEYCHAIN" \
| awk -F'"' '/"Developer ID Application/ {print $2; exit}'
)
if [[ -z "$IDENTITY" ]]; then
echo "::error::No Developer ID Application identity found in keychain after import"
exit 1
fi
echo "Identity: $IDENTITY"
echo "STACKNUDGE_SIGN_IDENTITY=$IDENTITY" >> $GITHUB_ENV
rm -f /tmp/cert.p12

- name: Build x86_64
run: bash build.sh x86_64
- name: Stash x86_64 binary
run: cp build/stack-nudge.app/Contents/MacOS/stack-nudge /tmp/stack-nudge-x86_64
# build.sh handles everything once env vars are set:
# - swiftc per-arch
# - bundle_venv (downloads python-build-standalone + pip installs stackvox)
# - sign_bundle recursively signs venv + .app with entitlements
- name: Build ${{ matrix.arch }}
run: bash build.sh ${{ matrix.arch }}

- name: Combine into universal binary
# Submit the signed .app to Apple's notarization service. ditto
# produces the zip Apple expects (preserves bundle metadata + xattrs).
# --wait blocks until the service responds (typically a few minutes).
# If notarization fails, the log dump points at which inner binary
# is the culprit.
- name: Notarize ${{ matrix.arch }}
env:
MACOS_NOTARY_API_KEY: ${{ secrets.MACOS_NOTARY_API_KEY }}
MACOS_NOTARY_API_KEY_ID: ${{ secrets.MACOS_NOTARY_API_KEY_ID }}
MACOS_NOTARY_API_ISSUER_ID: ${{ secrets.MACOS_NOTARY_API_ISSUER_ID }}
run: |
# The x86_64 build is what's currently in build/, so just replace
# its mach-o with the lipo'd universal one.
lipo -create /tmp/stack-nudge-arm64 /tmp/stack-nudge-x86_64 \
-output build/stack-nudge.app/Contents/MacOS/stack-nudge
# lipo invalidates the per-arch ad-hoc signatures — re-sign the bundle.
codesign --force --deep --sign - build/stack-nudge.app
file build/stack-nudge.app/Contents/MacOS/stack-nudge
set -euo pipefail
echo "$MACOS_NOTARY_API_KEY" | base64 --decode > /tmp/notary-key.p8
ditto -c -k --keepParent build/StackNudge.app /tmp/notarize.zip
xcrun notarytool submit /tmp/notarize.zip \
--key /tmp/notary-key.p8 \
--key-id "$MACOS_NOTARY_API_KEY_ID" \
--issuer "$MACOS_NOTARY_API_ISSUER_ID" \
--wait
xcrun stapler staple build/StackNudge.app
# Sanity: confirm the stapled bundle passes Gatekeeper's check.
spctl -a -vv build/StackNudge.app || true
rm -f /tmp/notary-key.p8 /tmp/notarize.zip

# Tarball includes the prebuilt bundle plus everything install.sh needs
# at runtime. install.sh skips the build step when build/stack-nudge.app
# is already present, so users who download the release run a fast
# install (no swiftc dependency on the user's machine).
- name: Package
- name: Package ${{ matrix.arch }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
mkdir -p release/build
cp -R build/stack-nudge.app release/build/
cp notify.sh install.sh uninstall.sh notify.conf.example release/
cp -R phrases release/
tar czf "stack-nudge-${VERSION}-universal.tar.gz" -C release .
shasum -a 256 "stack-nudge-${VERSION}-universal.tar.gz" \
| awk '{print $1}' > "stack-nudge-${VERSION}-universal.sha256"
ARTIFACT="stack-nudge-${VERSION}-macos-${{ matrix.arch }}.tar.gz"
# Tarball wraps just the .app — it's now self-contained
# (Bootstrap.swift owns the install side on first launch).
tar czf "$ARTIFACT" -C build StackNudge.app
shasum -a 256 "$ARTIFACT" | awk '{print $1 " " "'"$ARTIFACT"'"}' \
> "$ARTIFACT.sha256"
ls -la "$ARTIFACT" "$ARTIFACT.sha256"

# release-please creates the GitHub Release before this workflow runs,
# so action-gh-release attaches assets to the existing release rather
# than creating a duplicate.
# release-please creates the GitHub Release before this workflow
# runs, so action-gh-release attaches assets to the existing
# release rather than creating a duplicate.
- uses: softprops/action-gh-release@v3
with:
files: |
stack-nudge-*-universal.tar.gz
stack-nudge-*-universal.sha256
stack-nudge-*-macos-${{ matrix.arch }}.tar.gz
stack-nudge-*-macos-${{ matrix.arch }}.tar.gz.sha256

# Always clean up the temporary keychain — leaves no signing
# material on the runner if a subsequent job runs the same runner.
- name: Cleanup keychain
if: always()
run: |
security delete-keychain "$KEYCHAIN" || true
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ let package = Package(
// App entry points / resources are not library code.
"panel/main.swift",
"panel/Info.plist",
"panel/entitlements.plist",
"notifier",

// Top-level scripts, docs, and build artefacts.
Expand All @@ -39,6 +40,7 @@ let package = Package(
"CODE_OF_CONDUCT.md",
"SECURITY.md",
"CHANGELOG.md",
"ui_improvements.md",

// Directories not part of the testable surface.
"build",
Expand Down
76 changes: 63 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,55 @@

## Install

**Prerequisites:** Python ≥ 3.10 (the bundled voice engine [stackvox](https://github.com/StackOneHQ/stackvox) requires it). macOS ships 3.9 by default — install a newer one with `brew install python@3.13`, or set `STACKNUDGE_PYTHON=/path/to/python3` to point at one explicitly.
### macOS

Download the latest release from [GitHub Releases](https://github.com/StackOneHQ/stack-nudge/releases/latest):

```bash
# Pick the tarball matching your Mac's architecture:
# arm64 → Apple Silicon (M1/M2/M3/M4)
# x86_64 → Intel
curl -fsSLO https://github.com/StackOneHQ/stack-nudge/releases/latest/download/stack-nudge-macos-arm64.tar.gz
tar xzf stack-nudge-macos-arm64.tar.gz
mv stack-nudge.app ~/Applications/
open ~/Applications/stack-nudge.app
```

On first launch, stack-nudge runs a one-screen wizard:

1. Detects which agents you have configured (`~/.claude`, `~/.cursor`, `~/.gemini`).
2. Wires their hook configs to `notify.sh`.
3. Registers itself + the voice engine as launchd agents so they start at login.

Everything is self-contained inside the `.app` — no Xcode CLT, no Python, no shell-script bootstrap required. The bundle ships with a portable Python + stackvox (the offline voice engine) already installed. The Kokoro voice model downloads lazily the first time you enable voice notifications.

Subsequent releases install automatically via the in-app auto-updater (Settings → "Update available · vX.Y.Z" when a new release exists).

### Linux / Windows

These platforms get the audio + libnotify path only — no panel, no click-to-focus, no auto-update. The shell installer is what wires `notify.sh` into agent hooks:

```bash
git clone https://github.com/StackOneHQ/stack-nudge.git
cd stack-nudge
./install.sh
```

**Prerequisites:** Python ≥ 3.10 (the bundled voice engine [stackvox](https://github.com/StackOneHQ/stackvox) requires it).

The installer auto-wires hooks for **Claude Code** (`~/.claude/settings.json`) and **Cursor** (`~/.cursor/hooks.json`). Gemini CLI and Codex are supported through the same `notify.sh` entry-point, but their hooks must be wired manually — see [Manual setup](#manual-setup) below.

On macOS it also installs the native `stack-nudge.app`, which provides the floating panel, click-to-focus banners, auto-update, and quota tracking. The first launch will show a welcome screen with a "Grant permissions" button — taking that step up-front unlocks click-to-focus and the keystroke-based "Allow" approvals.
### From source (macOS dev)

If you're working on stack-nudge itself and want to build from source rather than download a release:

```bash
git clone https://github.com/StackOneHQ/stack-nudge.git
cd stack-nudge
./install.sh
```

Same script as Linux/Windows; on macOS it additionally builds and installs the panel `.app`. Requires Xcode CLT. See [Development](#development) for the inner-loop tools.

## How it works

Expand Down Expand Up @@ -187,12 +225,16 @@ If approval has stopped working after a rebuild, hit **Reset & prompt** in the p

stack-nudge polls GitHub Releases on launch and every 6 hours. When a newer release exists, the Settings tab gets a small accent dot and an "Update available · vX.Y.Z" row at the top of the list. Click it (or press Enter while it's selected) for a confirmation view with the release notes, then "Update Now" runs the install:

1. Clones the repo to `/tmp`
2. Runs `install.sh` against the cloned source (rebuild + replace `~/Applications/stack-nudge.app` + reload launchd)
3. After completion, the panel auto-quits; launchd brings up the new bundle
4. The new bundle's first launch shows a welcome-style "Updated to vX.Y.Z" screen with the release notes
1. Downloads the arch-appropriate `.tar.gz` artifact for your Mac (~150–200 MB)
2. Verifies the SHA256 against the sidecar checksum file
3. Extracts to a temp directory, strips the `com.apple.quarantine` xattr
4. Atomic-swaps `~/Applications/stack-nudge.app` with the new bundle (keeps the old as `.app.old` for safety)
5. Runs `launchctl kickstart -k` — the current process dies, launchd brings up the new bundle
6. The new bundle's first launch shows a welcome-style "Updated to vX.Y.Z" screen with the release notes

While the StackOne stack-nudge repo is private the auto-updater falls back to your local `gh` CLI auth (`gh api`) to read the release metadata; the in-app git clone uses your existing git credentials (keychain or SSH). Org members with `gh` configured see no friction.
No source clone, no swiftc rebuild on the user's machine — the new bundle is the already-signed-and-notarized artifact from CI. Updates are fast and don't disturb the user's Xcode CLT or Python install (or lack thereof).

While the StackOne stack-nudge repo is private the auto-updater falls back to your local `gh` CLI auth (`gh api`) to read the release metadata. Org members with `gh` configured see no friction; the actual artifact download uses the release's signed asset URL.

### Phrase editor

Expand Down Expand Up @@ -254,17 +296,25 @@ The Settings tab exposes the same picks with audio preview on each change.

## Uninstall

### macOS — in-app

Open the panel (`⌘⌥N`), go to **Settings → Uninstall stack-nudge…**, confirm. The app tears down:

- Hook entries in `~/.claude/settings.json`, `~/.cursor/hooks.json`, and `~/.gemini/settings.json`
- The launchd agents (`com.stackonehq.stack-nudge`, `…-daemon`)
- `~/.stack-nudge/` (config, `notify.sh`, phrases)
- Moves `stack-nudge.app` to Trash and quits

Settings (config, the cached Kokoro voice model in `~/.cache/huggingface/`, your macOS keychain entry for Claude Code) are not touched.

### Linux / Windows / fallback

```bash
git pull # if you cloned a while back — older uninstall.sh lacks hook cleanup
./uninstall.sh
```

Cleans up:

- Hook entries in `~/.claude/settings.json`, `~/.cursor/hooks.json`, and `~/.gemini/settings.json`
- The launchd agents (`com.stackonehq.stack-nudge`, `…-daemon`)
- `~/Applications/stack-nudge.app`
- `~/.stack-nudge/` (including the Python venv and `notify.sh`)
Same set of cleanups as the in-app path, useful when the .app isn't reachable or the in-app uninstall failed mid-flight.

## Manual setup

Expand Down
Loading