diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53a5062..72cfd02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 950090c..be72d75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,14 @@ 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: @@ -7,64 +16,129 @@ on: 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 diff --git a/Package.swift b/Package.swift index bb5be86..f76c0fd 100644 --- a/Package.swift +++ b/Package.swift @@ -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. @@ -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", diff --git a/README.md b/README.md index 9f97a70..3e4441d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,33 @@ ## 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 @@ -28,9 +54,21 @@ 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 @@ -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 @@ -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 diff --git a/build.sh b/build.sh index cf5d847..244c853 100755 --- a/build.sh +++ b/build.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -# Builds stack-nudge.app (single persistent binary: panel + banners + voice) +# Builds StackNudge.app (single persistent binary: panel + banners + voice) # Usage: ./build.sh [arm64|x86_64] (defaults to host arch) set -e ARCH="${1:-$(uname -m)}" -APP="build/stack-nudge.app" +APP="build/StackNudge.app" build_app() { local app="$1" @@ -28,9 +28,91 @@ build_app() { cp "$icon_path" "$contents/Resources/Icon.icns" fi + # Bundle the user-facing runtime payload (hook script, phrase pools, + # example config) into the .app so Bootstrap.swift can copy them out + # to ~/.stack-nudge/ on first launch. Previously these lived only at + # the repo root and install.sh copied them; now the .app is self- + # contained — drop in Applications/, no source clone needed. + local repo_root + repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + cp "$repo_root/notify.sh" "$contents/Resources/notify.sh" + chmod +x "$contents/Resources/notify.sh" + if [[ -d "$repo_root/phrases" ]]; then + cp -R "$repo_root/phrases" "$contents/Resources/phrases" + fi + if [[ -f "$repo_root/notify.conf.example" ]]; then + cp "$repo_root/notify.conf.example" "$contents/Resources/notify.conf.example" + fi + + # Optional: bundle a self-contained Python + stackvox into Resources/venv/. + # Skipped for local iteration (slow); enabled by CI for release artifacts. + # Opt-in via STACKNUDGE_BUNDLE_VENV=1. Bootstrap.swift gracefully handles + # the missing-venv case (skips daemon-plist registration, voice + # notifications become unavailable). + if [[ "${STACKNUDGE_BUNDLE_VENV:-0}" == "1" ]]; then + bundle_venv "$contents/Resources/venv" "$ARCH" + else + echo " (skipped venv bundle — STACKNUDGE_BUNDLE_VENV=1 to include voice engine)" + fi + sign_bundle "$app" } +# Download a portable Python from python-build-standalone, untar it into +# $venv_dir, and pip-install stackvox into its site-packages. Result is a +# self-contained Python install that the .app can ship as Resources/venv/. +# +# Pinned PBS release; bump when stackvox's Python requirement changes or +# Apple ships a Python.framework update that breaks the current bundle. +PBS_RELEASE="20250712" +PBS_PYTHON_VERSION="3.12.11" + +bundle_venv() { + local venv_dir="$1" + local arch="$2" + + echo "Bundling stackvox venv ($arch, python ${PBS_PYTHON_VERSION})..." + + local pbs_arch + case "$arch" in + arm64) pbs_arch="aarch64-apple-darwin" ;; + x86_64) pbs_arch="x86_64-apple-darwin" ;; + *) + echo " ! unknown arch '$arch' — skipping venv bundle" + return 0 + ;; + esac + + local url="https://github.com/indygreg/python-build-standalone/releases/download/${PBS_RELEASE}/cpython-${PBS_PYTHON_VERSION}+${PBS_RELEASE}-${pbs_arch}-install_only.tar.gz" + local cache="/tmp/stack-nudge-pbs-${pbs_arch}.tar.gz" + + if [[ ! -f "$cache" ]]; then + echo " Downloading $url" + curl -fsSL --retry 3 -o "$cache" "$url" + else + echo " Using cached $cache" + fi + + rm -rf "$venv_dir" + mkdir -p "$venv_dir" + # PBS tarballs unpack into a single `python/` directory at the top — + # strip it so our $venv_dir layout matches a normal Python prefix. + tar -xzf "$cache" -C "$venv_dir" --strip-components=1 + + # pip install stackvox into the bundled Python's site-packages directly + # (no nested virtualenv layer — keeps the bundle a few MB smaller and + # avoids a redundant Python symlink dance). + echo " Installing stackvox..." + "$venv_dir/bin/python3" -m pip install --no-cache-dir --quiet 'stackvox>=0.4.0' + + # Strip __pycache__ and pip caches to shrink the bundle. These can be + # regenerated by the bundled Python at first import — small startup + # cost, meaningful disk save (5-10% of bundle). + find "$venv_dir" -name '__pycache__' -prune -exec rm -rf {} + 2>/dev/null || true + + echo " Bundled venv at $venv_dir" +} + # Sign the bundle so Info.plist is bound into the signature. Without this, # macOS records the wrong identity for TCC (AXIsProcessTrusted = false). # @@ -45,10 +127,11 @@ build_app() { # cdhash changes on every build, which means TCC + Keychain prompts # re-fire on each rebuild and each release. # -# Hardened runtime (--options runtime) is enabled when a real identity is -# present so the signed bundle is notarisation-eligible. It's omitted from -# the ad-hoc path because it makes the binary slightly more restricted -# without any of the benefits (notarisation requires Developer ID). +# When a Developer ID is in play AND Resources/venv/ exists (CI release path), +# we recursively sign every binary inside the venv first (libs/exes/.so), +# applying hardened-runtime entitlements that the bundled Python interpreter +# needs to function. The outer .app is signed last so its signature includes +# the freshly-signed inner content. sign_bundle() { local app="$1" local identity="${STACKNUDGE_SIGN_IDENTITY:-}" @@ -61,14 +144,72 @@ sign_bundle() { fi if [[ -n "$identity" ]]; then - codesign --force --deep --options runtime --sign "$identity" "$app" + local entitlements + entitlements="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/panel/entitlements.plist" + + # Recursively sign bundled venv contents first when present. + if [[ -d "$app/Contents/Resources/venv" ]]; then + sign_venv_contents "$app/Contents/Resources/venv" "$identity" "$entitlements" + fi + + # Outer .app signed last; its sig pins the inner content via cdhash. + codesign --force --options runtime --sign "$identity" \ + --entitlements "$entitlements" "$app" echo " Signed: $identity" else + # Ad-hoc fallback. --deep recursively signs any nested code but + # without hardened runtime or entitlements (notarisation needs Dev + # ID anyway, so they'd be inert here). Bundle is fine for local + # iteration; doesn't notarise. codesign --force --deep --sign - "$app" echo " Signed: ad-hoc (no Developer ID Application cert in keychain)" fi } +# Recursively sign every native binary inside the bundled venv. Apple +# notarization requires every Mach-O inside the .app to be signed with +# our Developer ID + hardened runtime + the same entitlements. +sign_venv_contents() { + local venv="$1" + local identity="$2" + local entitlements="$3" + + echo " Signing venv contents..." + + # Find every Mach-O candidate: .dylib, .so, the bin/* executables, and + # the python framework's nested binaries. -print0 / xargs -0 handles + # spaces in paths (uncommon but defensive). + # + # Counter-intuitively we do NOT need to sign in depth-first order; we + # do need to sign EACH binary at least once before the outer .app is + # signed (which happens after this function returns). codesign --force + # makes the re-sign idempotent. + local signed=0 + while IFS= read -r -d '' file; do + codesign --force --options runtime --sign "$identity" \ + --entitlements "$entitlements" "$file" 2>/dev/null || true + signed=$((signed + 1)) + done < <( + find "$venv" \ + \( -name '*.dylib' -o -name '*.so' \) \ + -print0 + ) + + # Sign exec bits in bin/ (typically python3, pip, stackvox). + if [[ -d "$venv/bin" ]]; then + while IFS= read -r -d '' file; do + # Skip shebang scripts — they're not Mach-O; codesign would fail. + if file "$file" 2>/dev/null | grep -q 'Mach-O'; then + codesign --force --options runtime --sign "$identity" \ + --entitlements "$entitlements" "$file" 2>/dev/null || true + signed=$((signed + 1)) + fi + done < <(find "$venv/bin" -type f -perm -u+x -print0) + fi + + echo " Signed $signed venv binaries" +} + echo "Building stack-nudge ($ARCH)..." rm -rf build @@ -92,7 +233,7 @@ build_app "$APP" "stack-nudge" \ panel/Phrases.swift \ panel/UpdateChecker.swift \ panel/Updater.swift \ - panel/Welcome.swift \ + panel/Bootstrap.swift \ shared/AppActivator.swift \ -framework Foundation -framework AppKit -framework SwiftUI -framework Carbon \ -framework UserNotifications diff --git a/install.sh b/install.sh index f5a7dda..6704ec7 100755 --- a/install.sh +++ b/install.sh @@ -1,5 +1,21 @@ #!/usr/bin/env bash -# stack-nudge installer — wires up hooks for whichever agents you have +# stack-nudge installer +# +# macOS users: prefer the prebuilt .app from GitHub Releases — +# https://github.com/StackOneHQ/stack-nudge/releases/latest +# Download the .tar.gz, drag StackNudge.app to ~/Applications/, and +# launch it. The first-launch wizard runs the same install steps this +# script does, in-process, with no Xcode CLT or Python prerequisite. +# +# This script remains for: +# - Linux + Windows (where the panel .app doesn't apply; notify.sh + +# audio is all that's needed) +# - Source-build devs on macOS who want to iterate without the +# prebuilt cycle +# +# Wires hooks for whichever agents you have, sets up the Python venv +# for stackvox voice notifications, and registers launchd agents on +# macOS. set -e @@ -12,20 +28,20 @@ echo "Installing stack-nudge..." mkdir -p "$INSTALL_DIR" # Build (or use a prebuilt) native app bundle. Release tarballs ship with a -# universal binary already at build/stack-nudge.app — in that case skip the +# universal binary already at build/StackNudge.app — in that case skip the # rebuild so users who download a release don't need swiftc on their machine. # build.sh's output (Swift emits ~120 lines of UserNotifications deprecation # warnings on every build) goes to a log so the install transcript stays # scannable. On real build failure the log's last 20 lines are dumped. -PREBUILT_APP="$SCRIPT_DIR/build/stack-nudge.app" +PREBUILT_APP="$SCRIPT_DIR/build/StackNudge.app" BUILD_LOG="/tmp/stack-nudge-install-build.log" if [[ "$(uname -s)" == "Darwin" ]]; then echo "" echo "# STAGE: building" if [[ -d "$PREBUILT_APP" ]]; then - echo "Using prebuilt stack-nudge.app from release bundle..." + echo "Using prebuilt StackNudge.app from release bundle..." else - echo "Building stack-nudge.app..." + echo "Building StackNudge.app..." if ! bash "$SCRIPT_DIR/build.sh" > "$BUILD_LOG" 2>&1; then echo "" echo " ✗ Build failed. Last 20 lines of $BUILD_LOG:" @@ -33,10 +49,10 @@ if [[ "$(uname -s)" == "Darwin" ]]; then exit 1 fi fi - rm -rf "$HOME/Applications/stack-nudge.app" + rm -rf "$HOME/Applications/StackNudge.app" rm -rf "$HOME/Applications/stack-nudge-panel.app" # clean up old panel binary - cp -r "$PREBUILT_APP" "$HOME/Applications/stack-nudge.app" - echo " Installed stack-nudge.app -> ~/Applications/stack-nudge.app" + cp -r "$PREBUILT_APP" "$HOME/Applications/StackNudge.app" + echo " Installed StackNudge.app -> ~/Applications/StackNudge.app" fi # Pick a Python ≥ 3.10 for the venv. stackvox requires it, but `python3` on @@ -168,7 +184,7 @@ if [[ "$(uname -s)" == "Darwin" ]]; then "com.stackonehq.stack-nudge" \ "always" \ "${INSTALL_DIR}/app.log" \ - "$HOME/Applications/stack-nudge.app/Contents/MacOS/stack-nudge" + "$HOME/Applications/StackNudge.app/Contents/MacOS/stack-nudge" echo " App registered as launchd agent (starts at login)" # Remove old panel launchd agent if upgrading from two-binary setup diff --git a/notify.sh b/notify.sh index 991efd6..bd2cee0 100755 --- a/notify.sh +++ b/notify.sh @@ -71,13 +71,12 @@ voice_permission_context() { } -# Set to "true" to speak notifications aloud via StackVox (offline TTS). -# Requires: pip install stackvox && stackvox serve -# Optional: set STACKNUDGE_VOICE_NAME to a StackVox voice ID (default: af_aoede) -# Optional: set STACKNUDGE_VOICE_SPEED to playback speed (default: 1.1) -VOICE_ENABLED="${STACKNUDGE_VOICE:-false}" +# STACKNUDGE_VOICE_NAME is read by the .app to pick the Kokoro voice. We +# keep a local copy here only for phrase-language selection — voice_phrase_for +# uses it to pick the right phrases/.sh file. Voice playback itself +# is the app's job (see Speaker.swift) so afplay/stackvox children get +# torn down when the user quits the app. VOICE_NAME="${STACKNUDGE_VOICE_NAME:-af_aoede}" -VOICE_SPEED="${STACKNUDGE_VOICE_SPEED:-1.1}" # Map a Kokoro voice prefix to a phrase-file language code. voice_to_lang() { @@ -91,19 +90,6 @@ voice_to_lang() { esac } -# Map a Kokoro voice prefix to the --lang code stackvox expects. -voice_to_kokoro_lang() { - case "${1:0:2}" in - af|am) echo "en-us" ;; - bf|bm) echo "en-gb" ;; - ff) echo "fr-fr" ;; - hf|hm) echo "hi" ;; - if|im) echo "it" ;; - pf|pm) echo "pt-br" ;; - *) echo "en-us" ;; - esac -} - # Light expansion for stackvox: split hyphens/underscores, fix a couple of # stackvox-specific tokens that the model otherwise mispronounces. repo_name_raw() { @@ -244,34 +230,20 @@ nudge_debug() { printf '[stack-nudge] %s\n' "$*" >&2 } -# Speak a message aloud via the bundled StackVox daemon. -# Auto-starts the daemon if it isn't running. Falls back silently if the -# venv isn't installed or the daemon fails to respond — set STACKNUDGE_DEBUG=true -# to surface why. -speak_notification() { - [[ "${VOICE_ENABLED}" != "true" ]] && return - if [[ ! -x "$STACKVOX" ]]; then - nudge_debug "voice requested but stackvox not found at $STACKVOX" - return - fi - local text="$1" - if [[ ! -S "${HOME}/.cache/stackvox/daemon.sock" ]]; then - nudge_debug "stackvox daemon socket missing — starting daemon" - nohup "$STACKVOX" serve >/dev/null 2>&1 & - fi - local kokoro_lang - kokoro_lang=$(voice_to_kokoro_lang "$VOICE_NAME") - "$STACKVOX" say --voice "${VOICE_NAME}" --lang "${kokoro_lang}" --speed "${VOICE_SPEED}" "${text}" 2>/dev/null & -} - -# Locate one of our .app bundles. Searches ~/Applications, the script's -# own directory, and the repo build/ output (for in-tree development). -# Args: app-bundle-name (e.g. "stack-nudge.app") -# Echoes the first match, empty string if none found. +# Auto-launch the .app if the panel socket isn't already up. Checks the +# new bundle name first (StackNudge.app, 1.7+) then falls back to the +# pre-rename name (stack-nudge.app) so an in-flight upgrade — where the +# old bundle is still running with the new hook script — keeps working. ensure_app_running() { [[ -S "$PANEL_SOCK" ]] && return - local app_path="$HOME/Applications/stack-nudge.app" - [[ ! -d "$app_path" ]] && return + local app_path="" + if [[ -d "$HOME/Applications/StackNudge.app" ]]; then + app_path="$HOME/Applications/StackNudge.app" + elif [[ -d "$HOME/Applications/stack-nudge.app" ]]; then + app_path="$HOME/Applications/stack-nudge.app" + else + return + fi # -g: launch in the background, don't steal focus from the editor open -ga "$app_path" 2>/dev/null for _ in 1 2 3 4 5 6 7 8 9 10; do @@ -315,6 +287,7 @@ walk_session_chain() { # env vars rather than positional argv to keep the heredoc readable now that # we have ~15 fields. # Args: title message bundle_id window_title has_action(true|false) +# fifo_path voice_message sound_name bypass_mute(true|false) post_to_panel() { ensure_app_running [[ ! -S "$PANEL_SOCK" ]] && return @@ -331,6 +304,9 @@ post_to_panel() { NUDGE_IPC_HOOK="${VSCODE_IPC_HOOK_CLI:-}" \ NUDGE_HAS_ACTION="$5" \ NUDGE_FIFO="${6:-}" \ + NUDGE_VOICE_MESSAGE="${7:-}" \ + NUDGE_SOUND="${8:-}" \ + NUDGE_BYPASS_MUTE="${9:-false}" \ NUDGE_SOCK="$PANEL_SOCK" \ NUDGE_AGENT_PID="${AGENT_PID:-}" \ NUDGE_SHELL_PID="${SHELL_PID:-}" \ @@ -349,6 +325,7 @@ out = { "message": env["NUDGE_MESSAGE"], "timestamp": time.time(), "has_action_button": env["NUDGE_HAS_ACTION"] == "true", + "bypass_mute": env.get("NUDGE_BYPASS_MUTE", "false") == "true", } # Only emit fields that have values — keeps the wire payload clean. @@ -364,6 +341,8 @@ optional = { "terminal_app": env.get("NUDGE_TERMINAL_APP"), "term_program": env.get("NUDGE_TERM_PROGRAM"), "session_id": env.get("NUDGE_SESSION_ID"), + "voice_message": env.get("NUDGE_VOICE_MESSAGE"), + "sound_name": env.get("NUDGE_SOUND"), } for key, value in optional.items(): if not value: @@ -424,12 +403,12 @@ notify_macos() { esac # Identify the source window by matching the project name ($PWD basename) - # to window titles. This lets us suppress and focus the right window even - # when multiple windows of the same app are open. + # to window titles. The app uses this to disambiguate which editor window + # is the source (for both click-to-focus and mute-when-focused). local win_title="" + local project_name + project_name=$(basename "$PWD") if [[ -n "$process_name" ]]; then - local project_name - project_name=$(basename "$PWD") win_title=$(osascript \ -e "tell application \"System Events\"" \ -e " tell process \"${process_name}\"" \ @@ -440,36 +419,6 @@ notify_macos() { -e "end tell" 2>/dev/null) fi - # Suppress banner only if the exact source window is currently frontmost. - # Gated on STACKNUDGE_MUTE_WHEN_FOCUSED — set to false to always notify - # regardless of which window has focus. The welcome event always fires - # (post-install confirmation must reach the user even though they're - # staring at the install terminal at that moment). - local mute_when_focused="${STACKNUDGE_MUTE_WHEN_FOCUSED:-true}" - [[ "${EVENT}" == "welcome" ]] && mute_when_focused="false" - if [[ "$mute_when_focused" == "true" ]]; then - local frontmost_id - frontmost_id=$(osascript -e "id of app (path to frontmost application as text)" 2>/dev/null) - if [[ "$frontmost_id" == "$bundle_id" && -n "$process_name" && -n "$win_title" ]]; then - local frontmost_win - frontmost_win=$(osascript \ - -e "tell application \"System Events\"" \ - -e " tell process \"${process_name}\"" \ - -e " get title of window 1" \ - -e " end tell" \ - -e "end tell" 2>/dev/null) - if [[ "$frontmost_win" == "$win_title" ]]; then - # Source window is already focused — minimal signal. Skip sound when - # voice is on (voice itself is suppressed here too, but keep the - # "voice replaces sound" rule consistent across all paths). - if [[ "${VOICE_ENABLED}" != "true" ]]; then - afplay "/System/Library/Sounds/${sound}.aiff" 2>/dev/null - fi - return - fi - fi - fi - local has_action="false" local fifo_path="" if [[ "${EVENT}" == "permission" ]]; then @@ -477,18 +426,24 @@ notify_macos() { fifo_path=$(create_perm_fifo) fi - # Post to the persistent app — it handles both the panel history and the - # UNUserNotification banner based on the user's config. Backgrounded so - # Python startup (~50ms) doesn't block the agent hook. - post_to_panel "${title}" "${message}" "${bundle_id}" "${project_name:-}" "${has_action}" "${fifo_path}" & - - # Sound fires independently via afplay — guaranteed even if macOS throttles - # or the app isn't running yet. Voice replaces the chime when enabled. - if [[ "${VOICE_ENABLED}" != "true" ]]; then - afplay "/System/Library/Sounds/${sound}.aiff" 2>/dev/null & - fi - - speak_notification "${voice_message}" + # Audio (chime + voice) and mute-when-focused now live in the .app — + # so quitting stack-nudge actually silences the bell, and the cdhash- + # stable signed bundle can manage afplay/stackvox child lifetimes. + # This hook just forwards the curated phrase and sound name; the app + # decides whether to play them based on PanelConfig + frontmost window. + # + # The welcome event (legacy install.sh post-install confirmation) sets + # bypass_mute=true so the user hears it even while looking at the + # install terminal. + local bypass_mute="false" + [[ "${EVENT}" == "welcome" ]] && bypass_mute="true" + + # Pass the captured window title (not the project basename) so the + # in-app mute-when-focused check has the right value to compare + # against the frontmost window. project_name is still derived from $PWD + # via NUDGE_PROJECT inside post_to_panel. + post_to_panel "${title}" "${message}" "${bundle_id}" "${win_title}" \ + "${has_action}" "${fifo_path}" "${voice_message}" "${sound}" "${bypass_mute}" & # For permission events, block reading from the FIFO. The user's Allow # click in the panel/banner writes "allow" to it; we then output the diff --git a/panel/Bootstrap.swift b/panel/Bootstrap.swift new file mode 100644 index 0000000..a4a37ab --- /dev/null +++ b/panel/Bootstrap.swift @@ -0,0 +1,1133 @@ +import AppKit +import Foundation +import SwiftUI + +// Owns the install / uninstall of stack-nudge on this Mac. Replaces the +// shell-script install.sh / uninstall.sh paths for end users: the .app +// itself runs the first-launch wizard, wires hooks into agent configs, +// registers launchd agents, and tears all of it down again on uninstall. +// +// install.sh remains for Linux/Windows + source-build devs; the macOS +// flow now centres on this file. + +// MARK: - Agent + +// Which AI coding agent the user wants stack-nudge wired into. Detected +// by the presence of the agent's config directory under $HOME. +enum BootstrapAgent: String, CaseIterable, Identifiable, Equatable { + case claude + case cursor + case gemini + + var id: String { rawValue } + + var displayName: String { + switch self { + case .claude: return "Claude Code" + case .cursor: return "Cursor" + case .gemini: return "Gemini CLI" + } + } + + // Filesystem marker that signals the agent is installed locally. + // Mirrors the directory checks install.sh does. + var detectionDirectory: String { + switch self { + case .claude: return "\(NSHomeDirectory())/.claude" + case .cursor: return "\(NSHomeDirectory())/.cursor" + case .gemini: return "\(NSHomeDirectory())/.gemini" + } + } + + // Hook config file we write to when installing for this agent. Each + // agent has its own JSON shape; Bootstrap.install handles the splicing. + var hookConfigPath: String { + switch self { + case .claude: return "\(NSHomeDirectory())/.claude/settings.json" + case .cursor: return "\(NSHomeDirectory())/.cursor/hooks.json" + // Gemini hook support is experimental; install.sh writes nothing + // for it today. We mirror that — Bootstrap.install skips Gemini + // hook wiring. Selecting it is a no-op aside from acknowledging. + case .gemini: return "\(NSHomeDirectory())/.gemini/settings.json" + } + } +} + +// MARK: - Bootstrap + +enum Bootstrap { + + // MARK: Constants + + static let installDir = "\(NSHomeDirectory())/.stack-nudge" + static let notifyPath = "\(NSHomeDirectory())/.stack-nudge/notify.sh" + static let venvSymlinkPath = "\(NSHomeDirectory())/.stack-nudge/venv" + static let configPath = "\(NSHomeDirectory())/.stack-nudge/config" + static let phrasesDir = "\(NSHomeDirectory())/.stack-nudge/phrases" + + static let launchAgentsDir = "\(NSHomeDirectory())/Library/LaunchAgents" + static let appLabel = "com.stackonehq.stack-nudge" + static let daemonLabel = "com.stackonehq.stack-nudge-daemon" + + // Pattern matching any tinynudge/stack-nudge notify.sh reference in a + // hook command, including quoted paths. Same regex as uninstall.sh + // (loosened in #39 to handle quoted forms). + static let staleHookRegex = try? NSRegularExpression( + pattern: #"(?:^|/|")\.?(?:tinynudge|stack-nudge)/notify\.sh"# + ) + + // MARK: Detection + + // First-launch detection. Returns true when any of the install + // artifacts exist — i.e. a previous install (this session or a + // legacy install.sh run) has happened on this machine. + // + // Used at app startup to decide whether to show the bootstrap + // wizard or skip straight to normal panel operation. Permissive on + // purpose: any one of these signals is enough, so a partially- + // installed machine doesn't repeatedly re-trigger the wizard. + static func isInstalled() -> Bool { + // notify.sh is the authoritative marker — Bootstrap.install copies + // it as one of its first steps, and uninstall removes the whole + // dotdir. A standalone launchd plist is no longer sufficient + // (the bundle-rename migration used to write one before install + // had run, which falsely signalled "installed" on a fresh wizard). + FileManager.default.fileExists(atPath: notifyPath) + } + + // Path-level rename migration (pre-1.7 → 1.7+). The .app bundle is + // now `StackNudge.app` so Finder/Spotlight show the brand name; the + // CFBundle identifiers and the `~/.stack-nudge/` dotdir are untouched + // so existing TCC grants and user data carry over. Idempotent. + // + // Called from PanelController.applicationDidFinishLaunching when we + // detect we're running from the new path. Three things to fix up: + // 1. The old `stack-nudge.app` bundle next to us in ~/Applications/ + // (recycle it — keeps Finder tidy). + // 2. The launchd plist's `ProgramArguments[0]` still points at the + // old binary path. Rewrite + reload. + // 3. The agent hook entries reference the old `…/notify.sh` — + // already covered by the existing stale-entry regex, which + // matches both `tinynudge/` and `stack-nudge/`. Nothing to do. + static func migrateBundleNameIfNeeded() { + let fm = FileManager.default + let runningFromNewPath = Bundle.main.bundleURL.lastPathComponent == "StackNudge.app" + guard runningFromNewPath else { return } + + let legacy = "\(NSHomeDirectory())/Applications/stack-nudge.app" + if fm.fileExists(atPath: legacy) { + NSWorkspace.shared.recycle([URL(fileURLWithPath: legacy)]) { _, _ in } + } + + // Retarget existing launchd plists whose ProgramArguments still + // reference the pre-1.7 path. We intentionally do NOT create + // plists from scratch here — a missing plist means this is a + // fresh install (no migration needed) and Bootstrap.install will + // write them when the user finishes the wizard. + retargetLaunchAgentIfNeeded(label: appLabel) + retargetLaunchAgentIfNeeded(label: daemonLabel) + } + + // Read the on-disk launchd plist for `label`; if its first program- + // argument still references the pre-1.7 path, rewrite that argument + // to the equivalent path inside the currently-running bundle and + // reload the agent. No-op when the plist isn't present. + private static func retargetLaunchAgentIfNeeded(label: String) { + let fm = FileManager.default + let plistPath = "\(launchAgentsDir)/\(label).plist" + guard fm.fileExists(atPath: plistPath) else { return } + + guard let data = try? Data(contentsOf: URL(fileURLWithPath: plistPath)), + var plist = (try? PropertyListSerialization.propertyList( + from: data, options: [], format: nil)) as? [String: Any], + var args = plist["ProgramArguments"] as? [String], + let first = args.first, + first.contains("/stack-nudge.app/") + else { return } + + let newFirst = first.replacingOccurrences(of: "/stack-nudge.app/", with: "/StackNudge.app/") + args[0] = newFirst + plist["ProgramArguments"] = args + guard let updated = try? PropertyListSerialization.data( + fromPropertyList: plist, format: .xml, options: 0) else { return } + try? updated.write(to: URL(fileURLWithPath: plistPath), options: [.atomic]) + _ = try? runLaunchctl(["unload", plistPath]) + _ = try? runLaunchctl(["load", plistPath]) + } + + // Agents present on this Mac. The bootstrap wizard checks all of these + // by default; the user can untick to skip wiring any of them. + static func availableAgents() -> [BootstrapAgent] { + BootstrapAgent.allCases.filter { + FileManager.default.fileExists(atPath: $0.detectionDirectory) + } + } + + // MARK: Install + + // Install stack-nudge: copy bundled resources to ~/.stack-nudge/, + // splice hook entries into each selected agent's config, write + + // load launchd plists for the panel and the voice daemon. Reports + // progress via the callback (one line per step) so the UI can + // surface what's happening. + // + // Throws BootstrapError on any failure; the wizard surfaces these + // verbatim. Partial-install state is not rolled back automatically — + // a re-install will overwrite, and the uninstall path tolerates + // missing artifacts. + static func install( + agents: Set, + progress: @escaping (String) -> Void + ) throws { + let fm = FileManager.default + + progress("Creating \(installDir)…") + try fm.createDirectory(atPath: installDir, + withIntermediateDirectories: true) + + progress("Copying notify.sh…") + try copyBundledResource(named: "notify.sh", to: notifyPath) + _ = chmod(notifyPath, 0o755) + + progress("Copying phrase pools…") + // Wipe then recopy so reinstalls pick up new phrases. + try? fm.removeItem(atPath: phrasesDir) + try copyBundledResource(named: "phrases", to: phrasesDir) + + // Example config only when no live config exists — preserve user + // edits across re-installs. Matches install.sh's behavior. + if !fm.fileExists(atPath: configPath) { + if Bundle.main.url(forResource: "notify.conf.example", + withExtension: nil) != nil { + progress("Seeding default config…") + try copyBundledResource( + named: "notify.conf.example", + to: configPath + ) + } + } + + // Symlink ~/.stack-nudge/venv → bundle/Contents/Resources/venv if + // the bundle ships with stackvox. Local-dev builds may not bundle + // the venv (it's a CI-only step); the symlink step is skipped + // and voice notifications fall back gracefully. + try linkBundledVenvIfPresent(progress: progress) + + for agent in agents { + progress("Wiring hooks for \(agent.displayName)…") + try wireHooks(for: agent) + } + + progress("Writing launchd plists…") + try writePanelPlist() + try writeDaemonPlistIfVenvPresent() + + progress("Loading launchd agents…") + try loadLaunchdAgent(label: appLabel) + if hasBundledVenv() { + try loadLaunchdAgent(label: daemonLabel) + } + + progress("Install complete.") + } + + // MARK: Uninstall + + // Reverse of install. Best-effort: unloads and removes everything + // it can find, even on partial-install state, so a user uninstalling + // doesn't get stuck because one piece was already missing. Errors are + // logged via the progress callback but not thrown unless a critical + // step (hook-config rewrite) fails. + static func uninstall(progress: @escaping (String) -> Void) throws { + let fm = FileManager.default + + progress("Unloading launchd agents…") + for label in [appLabel, daemonLabel] { + let plist = "\(launchAgentsDir)/\(label).plist" + if fm.fileExists(atPath: plist) { + _ = try? runLaunchctl(["unload", plist]) + try? fm.removeItem(atPath: plist) + } + } + + progress("Removing hook entries…") + for agent in BootstrapAgent.allCases { + let path = agent.hookConfigPath + if fm.fileExists(atPath: path) { + try unwireHooks(at: path) + } + } + + progress("Removing \(installDir)…") + try? fm.removeItem(atPath: installDir) + + progress("Moving StackNudge.app to Trash…") + // NSWorkspace.recycle is async; we kick it off and let the + // current app terminate normally. macOS Finder handles the + // actual deletion once we exit. + NSWorkspace.shared.recycle([Bundle.main.bundleURL]) { _, _ in + DispatchQueue.main.async { + NSApp.terminate(nil) + } + } + } + + // MARK: - Resource copy helpers + + // Copy a file or directory from the .app's Resources/ into a + // destination path on disk. Wipes the destination first so the + // operation is idempotent (re-running install overwrites). + private static func copyBundledResource(named name: String, to dest: String) throws { + guard let src = Bundle.main.url(forResource: name, withExtension: nil) else { + throw BootstrapError.bundleResourceMissing(name) + } + let fm = FileManager.default + try? fm.removeItem(atPath: dest) + do { + try fm.copyItem(at: src, to: URL(fileURLWithPath: dest)) + } catch { + throw BootstrapError.copyFailed(name, underlying: error) + } + } + + // Bundle resource lookup that tolerates absence — returns the URL + // when the venv was bundled by CI, nil otherwise (local dev builds). + private static func bundledVenvURL() -> URL? { + Bundle.main.url(forResource: "venv", withExtension: nil) + } + + private static func hasBundledVenv() -> Bool { + guard let url = bundledVenvURL() else { return false } + return FileManager.default.fileExists( + atPath: url.appendingPathComponent("bin/stackvox").path + ) + } + + // Symlink the canonical ~/.stack-nudge/venv path to the bundled + // venv inside the .app. Notify.sh hardcodes the canonical path, so + // routing through the symlink keeps that script unchanged. + private static func linkBundledVenvIfPresent(progress: @escaping (String) -> Void) throws { + guard let venvURL = bundledVenvURL() else { + progress("(no bundled voice engine — skip symlink)") + return + } + let fm = FileManager.default + try? fm.removeItem(atPath: venvSymlinkPath) + do { + try fm.createSymbolicLink( + atPath: venvSymlinkPath, + withDestinationPath: venvURL.path + ) + progress("Linked voice engine → \(venvURL.path)") + } catch { + throw BootstrapError.writeFailed("venv symlink", underlying: error) + } + } + + // MARK: - Hook wiring (per-agent) + + // Splice our hook entry into the agent's JSON config, after removing + // any stale entries pointing at older tinynudge/stack-nudge installs. + // Swift port of the inline-Python blocks in install.sh. + private static func wireHooks(for agent: BootstrapAgent) throws { + let path = agent.hookConfigPath + switch agent { + case .claude: + try wireClaudeHooks(at: path) + case .cursor: + try wireCursorHooks(at: path) + case .gemini: + // install.sh just prints "experimental, see README" for + // Gemini today. Mirror that: no-op, but accept the agent + // in `agents` so the wizard checkbox does something + // (acknowledges the user's choice). + break + } + } + + private static func wireClaudeHooks(at path: String) throws { + var root = try readJSONObject(at: path) + var hooks = root["hooks"] as? [String: Any] ?? [:] + + // Two Claude events: Stop (turn ends, 30s timeout) and + // PermissionRequest (blocking on user approval, 600s). + let entries: [(event: String, arg: String, timeout: Int)] = [ + ("Stop", "stop", 30), + ("PermissionRequest", "permission", 600), + ] + + for (event, arg, timeout) in entries { + var groups = hooks[event] as? [[String: Any]] ?? [] + groups = pruneStaleHookGroups(groups) + let ourHook: [String: Any] = [ + "type": "command", + "command": "\(notifyPath) claude-code \(arg)", + "timeout": timeout, + ] + // Swift's [String: Any] doesn't preserve key order; the + // resulting JSON is still valid. Claude Code parses by key. + groups.append([ + "matcher": "", + "hooks": [ourHook], + ]) + hooks[event] = groups + } + + root["hooks"] = hooks + try writeJSONObject(root, to: path) + } + + private static func wireCursorHooks(at path: String) throws { + var root = try readJSONObject(at: path) + var hooks = root["hooks"] as? [String: Any] ?? [:] + + // Cursor has a single "stop" event; its shape is a flat array + // of hook entries (not the matcher-group nesting Claude uses). + var stops = hooks["stop"] as? [[String: Any]] ?? [] + stops = pruneStaleHookEntries(stops) + stops.append([ + "type": "command", + "command": "\(notifyPath) cursor stop", + ]) + hooks["stop"] = stops + + root["hooks"] = hooks + try writeJSONObject(root, to: path) + } + + // Remove any "group" (Claude's matcher-group shape) whose inner + // hooks reference a stale tinynudge/stack-nudge notify.sh. + private static func pruneStaleHookGroups(_ groups: [[String: Any]]) -> [[String: Any]] { + groups.compactMap { group in + let inner = group["hooks"] as? [[String: Any]] ?? [] + let kept = pruneStaleHookEntries(inner) + if kept.isEmpty { return nil } + if kept.count != inner.count { + var copy = group + copy["hooks"] = kept + return copy + } + return group + } + } + + // Remove individual hook entries (Cursor's flat shape) referencing + // a stale notify.sh path. Uses the same regex as uninstall.sh. + private static func pruneStaleHookEntries(_ entries: [[String: Any]]) -> [[String: Any]] { + entries.filter { entry in + let command = (entry["command"] as? String) ?? "" + return !isStaleHook(command: command) + } + } + + private static func isStaleHook(command: String) -> Bool { + guard let regex = staleHookRegex else { return false } + let range = NSRange(command.startIndex..., in: command) + return regex.firstMatch(in: command, options: [], range: range) != nil + } + + // Uninstall reverse: strip stale entries (the regex matches both old + // and current paths) from any agent config that still has them. + private static func unwireHooks(at path: String) throws { + var root = try readJSONObject(at: path) + guard var hooks = root["hooks"] as? [String: Any] else { return } + + // Claude shape: matcher-groups + for event in ["Stop", "PermissionRequest"] { + if let groups = hooks[event] as? [[String: Any]] { + let cleaned = pruneStaleHookGroups(groups) + if cleaned.isEmpty { + hooks.removeValue(forKey: event) + } else { + hooks[event] = cleaned + } + } + } + // Cursor shape: flat array + if let stops = hooks["stop"] as? [[String: Any]] { + let cleaned = pruneStaleHookEntries(stops) + if cleaned.isEmpty { + hooks.removeValue(forKey: "stop") + } else { + hooks["stop"] = cleaned + } + } + + if hooks.isEmpty { + root.removeValue(forKey: "hooks") + } else { + root["hooks"] = hooks + } + try writeJSONObject(root, to: path) + } + + // MARK: - JSON helpers + + private static func readJSONObject(at path: String) throws -> [String: Any] { + let fm = FileManager.default + guard fm.fileExists(atPath: path) else { return [:] } + let url = URL(fileURLWithPath: path) + let data = try Data(contentsOf: url) + if data.isEmpty { return [:] } + let parsed = try JSONSerialization.jsonObject(with: data) + return (parsed as? [String: Any]) ?? [:] + } + + private static func writeJSONObject(_ root: [String: Any], to path: String) throws { + let url = URL(fileURLWithPath: path) + let parent = url.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: parent, + withIntermediateDirectories: true + ) + let data = try JSONSerialization.data( + withJSONObject: root, + options: [.prettyPrinted, .sortedKeys] + ) + // Trailing newline so the file is well-formed for line-oriented + // tools (some editors get cranky without it). + var out = data + out.append(0x0A) + do { + try out.write(to: url, options: [.atomic]) + } catch { + throw BootstrapError.writeFailed(path, underlying: error) + } + } + + // MARK: - Launchd plist generation + + // Write the panel launchd plist (com.stackonehq.stack-nudge). Points + // at the current bundle's executable so a moved .app naturally + // re-anchors on next install. KeepAlive + RunAtLoad mirror install.sh. + private static func writePanelPlist() throws { + let binary = Bundle.main.bundleURL + .appendingPathComponent("Contents/MacOS/stack-nudge").path + let logPath = "\(installDir)/app.log" + try writePlist(label: appLabel, + programArgs: [binary], + logPath: logPath) + } + + // Write the voice-daemon launchd plist only when the bundle ships + // with stackvox. Points directly at the bundled binary path; no + // dependency on the venv symlink (which exists for notify.sh's + // benefit, not the daemon's). + private static func writeDaemonPlistIfVenvPresent() throws { + guard let venvURL = bundledVenvURL() else { return } + let stackvox = venvURL.appendingPathComponent("bin/stackvox").path + let logPath = "\(installDir)/daemon.log" + try writePlist(label: daemonLabel, + programArgs: [stackvox, "serve"], + logPath: logPath, + env: stackvoxEnv(venvURL: venvURL)) + } + + // libespeak-ng.dylib inside the bundled espeakng_loader wheel was + // compiled on the CI runner with its phoneme-data dir baked in + // (/Users/runner/work/...) — that path doesn't exist on user + // machines, so phonemization fails before any audio is generated. + // ESPEAK_DATA_PATH overrides the compile-time path at runtime; point + // it at the espeak-ng-data dir that ships inside the wheel. + static func stackvoxEnv(venvURL: URL) -> [String: String] { + let dataDir = venvURL + .appendingPathComponent("lib/python3.12/site-packages/espeakng_loader/espeak-ng-data") + .path + return ["ESPEAK_DATA_PATH": dataDir] + } + + // Common plist serialiser: emits the same XML shape install.sh's + // register_launchd_agent function produces, via PropertyListSerialization. + private static func writePlist(label: String, + programArgs: [String], + logPath: String, + env: [String: String] = [:]) throws { + var plist: [String: Any] = [ + "Label": label, + "ProgramArguments": programArgs, + "RunAtLoad": true, + "KeepAlive": true, + "StandardOutPath": logPath, + "StandardErrorPath": logPath, + ] + if !env.isEmpty { + plist["EnvironmentVariables"] = env + } + let data = try PropertyListSerialization.data( + fromPropertyList: plist, + format: .xml, + options: 0 + ) + let path = "\(launchAgentsDir)/\(label).plist" + try FileManager.default.createDirectory( + atPath: launchAgentsDir, + withIntermediateDirectories: true + ) + do { + try data.write(to: URL(fileURLWithPath: path), options: [.atomic]) + } catch { + throw BootstrapError.writeFailed(path, underlying: error) + } + } + + // MARK: - Launchctl + + private static func loadLaunchdAgent(label: String) throws { + let plist = "\(launchAgentsDir)/\(label).plist" + // Unload first (no-op if not loaded) to handle the re-install case + // where a previous .plist still has an agent active. + _ = try? runLaunchctl(["unload", plist]) + let result = try runLaunchctl(["load", plist]) + if result.exitCode != 0 { + throw BootstrapError.launchctlFailed( + label: label, + exitCode: result.exitCode, + stderr: result.stderr + ) + } + } + + private struct LaunchctlResult { + let exitCode: Int32 + let stderr: String + } + + private static func runLaunchctl(_ args: [String]) throws -> LaunchctlResult { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/bin/launchctl") + task.arguments = args + let errPipe = Pipe() + task.standardError = errPipe + task.standardOutput = Pipe() // discard stdout + try task.run() + let errData = errPipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + return LaunchctlResult( + exitCode: task.terminationStatus, + stderr: String(data: errData, encoding: .utf8) ?? "" + ) + } +} + +// MARK: - Bootstrap UI state + +// Phase of the bootstrap install. Drives BootstrapView's rendering. +enum BootstrapPhase: Equatable { + case idle // pre-install: wizard with agent checklist + case installing // running Bootstrap.install + case done // install complete; ready to dismiss + case failed(String) // install failed with this error message +} + +// Phase of the uninstall flow. Drives UninstallView's rendering. +enum UninstallPhase: Equatable { + case confirm // confirmation alert with Cancel/Uninstall + case uninstalling // running Bootstrap.uninstall + case failed(String) // failed mid-uninstall (rare; partial-state allowed) +} + +// MARK: - Bootstrap view (first-launch wizard) + +// Single-screen first-launch experience. Shown automatically when the app +// detects no prior install (Bootstrap.isInstalled() == false). Walks the +// user through three phases: +// +// .idle — pick which detected agents to wire up + Install button +// .installing — progress streamed from Bootstrap.install's callbacks +// .done — onboarding content (hotkey hint, tabs summary, Grant +// Permissions button) + Continue to drop into the events tab +// +// Folds in what used to be a separate Welcome.swift screen — the two were +// effectively two consecutive "Welcome to stack-nudge" screens, which felt +// redundant. Now first-launch is one cohesive flow. +struct BootstrapView: View { + + @ObservedObject var nav: PanelNav + let hotkeyDisplay: String + let onInstall: () -> Void + let onGrantPermissions: () -> Void + let onQuit: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + header + phaseBody + } + .padding(.horizontal, 18) + .padding(.vertical, 18) + .background(ThinScrollers()) + } + actionBar + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var header: some View { + HStack(spacing: 10) { + Image(systemName: headerIcon) + .font(.title3) + .foregroundStyle(headerIconColor) + Text(headerTitle) + .font(.title3.weight(.semibold)) + Spacer() + } + } + + private var headerIcon: String { + switch nav.bootstrapPhase { + case .done: return "checkmark.seal.fill" + case .failed: return "exclamationmark.triangle.fill" + default: return "bell.badge.fill" + } + } + + private var headerIconColor: Color { + switch nav.bootstrapPhase { + case .done: return .green + case .failed: return .red + default: return .accentColor + } + } + + private var headerTitle: String { + switch nav.bootstrapPhase { + case .idle: return "Welcome to StackNudge" + case .installing: return "Setting up…" + case .done: return "You're all set" + case .failed: return "Setup failed" + } + } + + @ViewBuilder + private var phaseBody: some View { + switch nav.bootstrapPhase { + case .idle: + tagline + agentList + case .installing, .failed: + progress + case .done: + completedBlurb + hotkeyHint + tabsSummary + permissionsHint + } + } + + private var tagline: some View { + Text("Notifications for AI coding agents. We'll wire StackNudge into each agent you've selected below, set up background services, and you'll be ready to go in a few seconds.") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + // MARK: - Post-install onboarding content (was Welcome.swift) + + private var completedBlurb: some View { + Text("StackNudge runs from your menu bar. Here's how to use it:") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + private var hotkeyHint: some View { + HStack(spacing: 8) { + Text("Press") + .font(.subheadline) + .foregroundStyle(.secondary) + HStack(spacing: 3) { + ForEach(hotkeyDisplay.keyCapTokens, id: \.self) { token in + KeyCapView(symbol: token) + } + } + Text("anytime to open this panel.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + private var tabsSummary: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Four tabs") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .padding(.bottom, 2) + + tabRow(systemImage: "bell.fill", + title: "Events", + detail: "Recent nudges; approve and focus with the keyboard") + tabRow(systemImage: "list.bullet.rectangle", + title: "Sessions", + detail: "Running agents you can focus, rename, or terminate") + tabRow(systemImage: "chart.bar.fill", + title: "Usage", + detail: "Claude Code quota — session, weekly, per-model") + tabRow(systemImage: "gearshape.fill", + title: "Settings", + detail: "Hotkey, sounds, voice, and more") + } + } + + private var permissionsHint: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "lock.shield.fill") + .font(.callout) + .foregroundStyle(Color.orange.opacity(0.8)) + .frame(width: 20, alignment: .center) + .padding(.top, 2) + Text("Notifications and Accessibility permissions are needed for banners and 'Allow' approvals. You can grant them now or later from Settings.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func tabRow(systemImage: String, title: String, detail: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: systemImage) + .font(.callout) + .foregroundStyle(Color.accentColor.opacity(0.8)) + .frame(width: 20, alignment: .center) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 1) { + Text(title).font(.subheadline.weight(.medium)) + Text(detail).font(.caption).foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var agentList: some View { + if nav.bootstrapAvailableAgents.isEmpty { + Text("No supported agents detected (~/.claude, ~/.cursor, ~/.gemini). Install one and restart StackNudge to wire it up.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } else { + VStack(alignment: .leading, spacing: 6) { + Text("Detected agents") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .padding(.bottom, 2) + ForEach(nav.bootstrapAvailableAgents) { agent in + agentRow(agent) + } + } + } + } + + @ViewBuilder + private func agentRow(_ agent: BootstrapAgent) -> some View { + if agent == .gemini { + // Gemini hook wiring isn't implemented — show the row as + // informational only so the user doesn't think they can toggle + // it on. + HStack(alignment: .top, spacing: 10) { + Image(systemName: "info.circle") + .font(.callout) + .foregroundStyle(.tertiary) + .frame(width: 20, alignment: .center) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 1) { + Text(agent.displayName).font(.subheadline.weight(.medium)) + Text("Detected, but hook wiring is manual. See README for setup.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + } else { + let isSelected = nav.bootstrapSelectedAgents.contains(agent) + HStack(alignment: .top, spacing: 10) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.callout) + .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) + .frame(width: 20, alignment: .center) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 1) { + Text(agent.displayName).font(.subheadline.weight(.medium)) + Text("Hooks will be added to \((agent.hookConfigPath as NSString).abbreviatingWithTildeInPath)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { + if isSelected { + nav.bootstrapSelectedAgents.remove(agent) + } else { + nav.bootstrapSelectedAgents.insert(agent) + } + } + } + } + + @ViewBuilder + private var progress: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + if case .installing = nav.bootstrapPhase { + ProgressView().controlSize(.small) + Text("Installing StackNudge…").font(.subheadline) + } else if case .done = nav.bootstrapPhase { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.title3) + Text("Install complete").font(.subheadline.weight(.medium)) + } else if case .failed = nav.bootstrapPhase { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + .font(.title3) + Text("Install failed").font(.subheadline.weight(.medium)) + } + } + // Tail of the progress log — last few lines, monospaced. + // No full scrollback; this is a transient view. + Text(nav.bootstrapLog.isEmpty ? "Starting…" : nav.bootstrapLog) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.05)) + ) + if case .failed(let msg) = nav.bootstrapPhase { + Text(msg) + .font(.caption) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private var actionBar: some View { + HStack(spacing: 10) { + // Left-side button: Quit when idle/failed, Grant Permissions when done. + if nav.bootstrapPhase == .idle || isFailed { + secondaryButton(label: "Quit", action: onQuit) + } else if case .done = nav.bootstrapPhase { + secondaryButton(label: "Grant permissions", action: onGrantPermissions) + } + + Spacer() + + // Right-side primary: Set up when idle, Continue when done. + if nav.bootstrapPhase == .idle { + // Always enabled — even with no agents selected, the install + // copies bundled resources + registers launchd agents, which + // is still useful. The user can wire hooks manually later. + primaryButton(label: "Set up", action: onInstall) + } else if case .done = nav.bootstrapPhase { + primaryButton(label: "Continue") { nav.mode = .events } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + .background( + ZStack { + Color.primary.opacity(0.05) + Rectangle() + .fill(Color.primary.opacity(0.1)) + .frame(height: 0.5) + .frame(maxHeight: .infinity, alignment: .top) + } + ) + } + + private func secondaryButton(label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(label) + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.08)) + ) + } + + private func primaryButton(label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 6) { + Text(label).font(.subheadline.weight(.medium)) + KeyCapView(symbol: "⏎") + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.accentColor.opacity(0.25)) + ) + } + + private var isFailed: Bool { + if case .failed = nav.bootstrapPhase { return true } + return false + } +} + +// Split a hotkey spec like "cmd+opt+n" into the key cap tokens BootstrapView +// renders. Modifier names map to the macOS glyphs the rest of the panel +// uses; everything else is uppercased verbatim. +private extension String { + var keyCapTokens: [String] { + split(separator: "+").map { part in + let p = part.trimmingCharacters(in: .whitespaces).lowercased() + switch p { + case "cmd", "command": return "⌘" + case "shift": return "⇧" + case "opt", "alt", "option": return "⌥" + case "ctrl", "control": return "⌃" + default: return p.uppercased() + } + } + } +} + +// MARK: - Uninstall view + +// Two-step uninstall: confirmation alert → progress → app quits. +// Mirrors UpdatingView's spinner+progress pattern for the running phase. +struct UninstallView: View { + + @ObservedObject var nav: PanelNav + let onCancel: () -> Void + let onConfirm: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + header + if nav.uninstallPhase == .confirm { + confirmCopy + } else { + progress + } + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + .background(ThinScrollers()) + } + footer + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var header: some View { + HStack(alignment: .center, spacing: 10) { + Image(systemName: "trash.circle.fill") + .font(.title2) + .foregroundStyle(.red) + VStack(alignment: .leading, spacing: 2) { + Text(nav.uninstallPhase == .confirm + ? "Remove StackNudge?" + : "Uninstalling…") + .font(.headline) + if nav.uninstallPhase == .confirm { + Text("This action is permanent.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + } + } + + private var confirmCopy: some View { + VStack(alignment: .leading, spacing: 8) { + Text("The following will be removed from your Mac:") + .font(.callout) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 4) { + bullet("Hook entries in your Claude Code / Cursor configs") + bullet("Background launchd agents (panel + voice daemon)") + bullet("~/.stack-nudge/ (config, phrases, notify.sh)") + bullet("StackNudge.app (moved to Trash)") + } + Text("Settings, the macOS keychain entry for Claude Code, and the cached Kokoro voice model in ~/.cache/huggingface/ are not touched.") + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.top, 4) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func bullet(_ text: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("•").foregroundStyle(.secondary) + Text(text) + .font(.callout) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + } + } + + @ViewBuilder + private var progress: some View { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Tearing down…").font(.subheadline) + } + Text(nav.uninstallLog.isEmpty ? "Starting…" : nav.uninstallLog) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.05)) + ) + if case .failed(let msg) = nav.uninstallPhase { + Text(msg) + .font(.caption) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var footer: some View { + PageFooter { + if nav.uninstallPhase == .confirm { + FooterHint(label: "Uninstall", keys: ["⏎"], primary: true) + FooterDivider() + FooterHint(label: "Cancel", keys: ["esc"]) + } else { + FooterHint(label: "Don't quit StackNudge during uninstall", keys: []) + } + } + } +} + +// MARK: - Errors + +enum BootstrapError: LocalizedError { + case copyFailed(String, underlying: Error) + case writeFailed(String, underlying: Error) + case launchctlFailed(label: String, exitCode: Int32, stderr: String) + case bundleResourceMissing(String) + + var errorDescription: String? { + switch self { + case .copyFailed(let what, let err): + return "Failed to copy \(what): \(err.localizedDescription)" + case .writeFailed(let what, let err): + return "Failed to write \(what): \(err.localizedDescription)" + case .launchctlFailed(let label, let code, let stderr): + let msg = stderr.isEmpty ? "exit \(code)" : stderr.trimmingCharacters(in: .whitespacesAndNewlines) + return "launchctl failed for \(label): \(msg)" + case .bundleResourceMissing(let name): + return "Bundled resource missing: \(name) (rebuild may be incomplete)" + } + } +} diff --git a/panel/Config.swift b/panel/Config.swift index 1db537c..d5d3719 100644 --- a/panel/Config.swift +++ b/panel/Config.swift @@ -6,7 +6,12 @@ import Foundation struct PanelConfig { var hotkeySpec: String = "cmd+opt+n" var bannerEnabled: Bool = true + var soundEnabled: Bool = true var activateImmediately: Bool = false + var voiceEnabled: Bool = false + var voiceName: String? = nil + var voiceSpeed: String? = nil + var muteWhenFocused: Bool = true static func load() -> PanelConfig { var config = PanelConfig() @@ -24,7 +29,12 @@ struct PanelConfig { switch key { case "STACKNUDGE_PANEL_HOTKEY": config.hotkeySpec = value case "STACKNUDGE_BANNER": config.bannerEnabled = value.lowercased() != "false" + case "STACKNUDGE_SOUND": config.soundEnabled = value.lowercased() != "false" case "STACKNUDGE_ACTIVATE_IMMEDIATELY": config.activateImmediately = value.lowercased() == "true" + case "STACKNUDGE_VOICE": config.voiceEnabled = value.lowercased() == "true" + case "STACKNUDGE_VOICE_NAME": config.voiceName = value + case "STACKNUDGE_VOICE_SPEED": config.voiceSpeed = value + case "STACKNUDGE_MUTE_WHEN_FOCUSED": config.muteWhenFocused = value.lowercased() != "false" default: break } } diff --git a/panel/EventListener.swift b/panel/EventListener.swift index 531da61..7d51547 100644 --- a/panel/EventListener.swift +++ b/panel/EventListener.swift @@ -124,6 +124,9 @@ private struct NudgeEventDTO: Decodable { let term_program: String? let session_id: String? let fifo_path: String? + let voice_message: String? + let sound_name: String? + let bypass_mute: Bool? func toNudgeEvent() -> NudgeEvent { NudgeEvent( @@ -143,7 +146,10 @@ private struct NudgeEventDTO: Decodable { terminalApp: terminal_app, termProgram: term_program, sessionID: session_id, - fifoPath: fifo_path + fifoPath: fifo_path, + voiceMessage: voice_message, + soundName: sound_name, + bypassMute: bypass_mute ?? false ) } } diff --git a/panel/EventStore.swift b/panel/EventStore.swift index ad1304d..dcafb61 100644 --- a/panel/EventStore.swift +++ b/panel/EventStore.swift @@ -42,6 +42,18 @@ struct NudgeEvent: Identifiable, Equatable { // "allow" or "deny" to it lets stack-nudge return a PermissionRequest // decision to Claude Code without touching the terminal UI. let fifoPath: String? + // Curated phrase for the voice engine (different from the visible + // `message` — the banner shows the tool / file context, the voice + // speaks a conversational sentence). + let voiceMessage: String? + // Name of a /System/Library/Sounds/*.aiff chime to play. Picked by + // notify.sh based on event kind (Glass for stop, Ping for permission) + // and overridable via STACKNUDGE_SOUND_STOP / STACKNUDGE_SOUND_PERMISSION. + let soundName: String? + // When true, this event bypasses the mute-when-focused gate. Used by + // the legacy install.sh `welcome` event, fired while the user is + // staring at the install terminal. + let bypassMute: Bool init(agent: String, kind: NudgeKind, title: String, message: String, projectPath: String? = nil, bundleID: String? = nil, @@ -51,6 +63,9 @@ struct NudgeEvent: Identifiable, Equatable { terminalPID: Int? = nil, terminalApp: String? = nil, termProgram: String? = nil, sessionID: String? = nil, fifoPath: String? = nil, + voiceMessage: String? = nil, + soundName: String? = nil, + bypassMute: Bool = false, snoozedUntil: Date? = nil, id: UUID = UUID()) { self.id = id @@ -71,6 +86,9 @@ struct NudgeEvent: Identifiable, Equatable { self.termProgram = termProgram self.sessionID = sessionID self.fifoPath = fifoPath + self.voiceMessage = voiceMessage + self.soundName = soundName + self.bypassMute = bypassMute self.snoozedUntil = snoozedUntil } @@ -85,7 +103,11 @@ struct NudgeEvent: Identifiable, Equatable { agentPID: agentPID, shellPID: shellPID, terminalPID: terminalPID, terminalApp: terminalApp, termProgram: termProgram, sessionID: sessionID, - fifoPath: fifoPath, snoozedUntil: snoozedUntil, + fifoPath: fifoPath, + voiceMessage: voiceMessage, + soundName: soundName, + bypassMute: bypassMute, + snoozedUntil: snoozedUntil, id: id // preserve identity across snooze cycles ) } diff --git a/panel/Info.plist b/panel/Info.plist index 2700e62..3a2d5ce 100644 --- a/panel/Info.plist +++ b/panel/Info.plist @@ -5,7 +5,9 @@ CFBundleIdentifier com.stackonehq.stack-nudge CFBundleName - stack-nudge + StackNudge + CFBundleDisplayName + StackNudge CFBundleExecutable stack-nudge CFBundleIconFile @@ -23,6 +25,6 @@ NSUserNotificationAlertStyle alert NSAppleEventsUsageDescription - stack-nudge uses System Events to focus the correct window when you act on a notification. + StackNudge uses System Events to focus the correct window when you act on a notification. diff --git a/panel/MenuBar.swift b/panel/MenuBar.swift index e68ba03..fca5ba9 100644 --- a/panel/MenuBar.swift +++ b/panel/MenuBar.swift @@ -93,7 +93,7 @@ final class MenuBarController: NSObject, NSMenuDelegate { super.init() if let button = statusItem.button { - button.image = NSImage(systemSymbolName: "bell", accessibilityDescription: "stack-nudge") + button.image = NSImage(systemSymbolName: "bell", accessibilityDescription: "StackNudge") button.image?.isTemplate = true } @@ -130,7 +130,7 @@ final class MenuBarController: NSObject, NSMenuDelegate { menu.addItem(action("Open config file…", #selector(openConfigAction))) menu.addItem(.separator()) - menu.addItem(action("Quit stack-nudge panel", #selector(quitAction), keyEquivalent: "q")) + menu.addItem(action("Quit StackNudge panel", #selector(quitAction), keyEquivalent: "q")) } private func toggle(_ title: String, state: Bool, key: String) -> NSMenuItem { @@ -162,13 +162,13 @@ final class MenuBarController: NSObject, NSMenuDelegate { // Confirmation banner via the existing notifier app — same channel a real // nudge would use, so the user sees exactly what's being enabled. private func fireBanner(message: String) { - let appPath = "\(NSHomeDirectory())/Applications/stack-nudge.app" + let appPath = "\(NSHomeDirectory())/Applications/StackNudge.app" guard FileManager.default.fileExists(atPath: appPath) else { return } let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/open") task.arguments = [ "-a", appPath, "--args", - "--title", "stack-nudge", + "--title", "StackNudge", "--message", message, "--sound", "Glass", ] diff --git a/panel/Panel.swift b/panel/Panel.swift index 6c60657..d3b50a0 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -38,7 +38,7 @@ final class FloatingPanel: NSPanel { init(contentRect: NSRect) { super.init(contentRect: contentRect, - styleMask: [.borderless, .nonactivatingPanel], + styleMask: [.borderless, .resizable, .nonactivatingPanel], backing: .buffered, defer: false) self.level = .floating self.isFloatingPanel = true @@ -49,6 +49,11 @@ final class FloatingPanel: NSPanel { self.isMovableByWindowBackground = true self.isReleasedWhenClosed = false self.hasShadow = true + // borderless + resizable: no visible chrome but mouse-drag on edges + // still works (standard Mac borderless-but-resizable pattern). + // contentMinSize keeps the layout from breaking; no max — let users + // expand to whatever fits their workflow. + self.contentMinSize = NSSize(width: 340, height: 240) } override var canBecomeKey: Bool { true } @@ -73,10 +78,16 @@ struct PanelContentView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - if !nav.welcomed { - WelcomeView(nav: nav, - hotkeyDisplay: nav.hotkeyDisplay, - onGrantPermissions: onGrantPermissions) + if nav.mode == .bootstrap { + // Full-screen first-launch experience: install + onboarding + // + Grant Permissions, all in one cohesive flow. + BootstrapView( + nav: nav, + hotkeyDisplay: nav.hotkeyDisplay, + onInstall: { nav.actions?.runBootstrap() }, + onGrantPermissions: onGrantPermissions, + onQuit: { NSApp.terminate(nil) } + ) } else if nav.mode == .postUpdate { // Full-screen takeover, no tab strip — matches welcome's // single-purpose first-launch feel. @@ -102,7 +113,14 @@ struct PanelContentView: View { onConfirm: { nav.actions?.runUpdate() } ) case .updating: UpdatingView(nav: nav) - case .postUpdate: EmptyView() // handled above + case .postUpdate: EmptyView() // handled above + case .bootstrap: EmptyView() // handled above + case .uninstall: + UninstallView( + nav: nav, + onCancel: { nav.mode = .settings }, + onConfirm: { nav.actions?.runUninstall() } + ) } } } @@ -335,10 +353,31 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // Tracks whether the banner has already fired this period per tier so // we don't refire on every poll. Reset when the tier's resets_at // advances (a new period started, fresh budget). - private var quotaLastFired: [String: (resetsAt: Date?, fired: Bool)] = [:] + // Per-tier alert state. `maxBucketFired` is the highest 5%-bucket + // (80, 85, 90, …) we've already alerted on; further alerts only fire + // when utilization crosses into a *new* higher bucket. `peakUtil` + // detects period rollover heuristically — a >30 pp drop from the + // running peak resets the bucket gate (the 5-hour window's + // resets_at slides forward every poll so we can't trust it). + private var quotaLastFired: [String: (maxBucketFired: Int, peakUtil: Double)] = [:] + + // UserDefaults keys for panel size + origin persistence. UserDefaults + // lives in ~/Library/Preferences/com.stackonehq.stack-nudge.plist, so it + // survives uninstall/reinstall cycles of ~/.stack-nudge/ and across + // app updates that swap the .app bundle. + private static let panelSizeKey = "PanelSize" + private static let panelOriginKey = "PanelOrigin" + private static let panelDefaultSize = NSSize(width: 420, height: 280) func applicationDidFinishLaunching(_ notification: Notification) { - let frame = NSRect(x: 0, y: 0, width: 420, height: 280) + // Pre-1.7 users had `stack-nudge.app` in ~/Applications/. If we're + // running from the new `StackNudge.app` location, scrub the + // stale bundle + rewrite the launchd plist so launchctl points + // at us, not the old path. + Bootstrap.migrateBundleNameIfNeeded() + + let size = Self.loadSavedPanelSize() + let frame = NSRect(origin: .zero, size: size) panel = FloatingPanel(contentRect: frame) panel.keyDelegate = self @@ -355,6 +394,12 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, store: store, sessions: sessions, nav: nav, phrases: phrases, onGrantPermissions: { [weak self] in self?.handleGrantPermissions() } )) + // Don't let SwiftUI's preferred / intrinsic content size drive + // the NSPanel frame. The panel is user-resizable + size-persisted; + // a tab whose root view reports a different sizeThatFits (e.g., + // the Loading-quota empty state) was causing the window to + // resize on every switch. + host.sizingOptions = [] host.translatesAutoresizingMaskIntoConstraints = false blur.addSubview(host) NSLayoutConstraint.activate([ @@ -366,6 +411,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, panel.contentView = blur positionPanel() + observePanelFrameChanges() let config = PanelConfig.load() nav.hotkeyDisplay = config.hotkeySpec @@ -390,6 +436,9 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, }, beginUpdate: { [weak self] in self?.beginUpdateFlow() }, runUpdate: { [weak self] in self?.updater?.run() }, + beginUninstall: { [weak self] in self?.beginUninstallFlow() }, + runUninstall: { [weak self] in self?.runUninstall() }, + runBootstrap: { [weak self] in self?.runBootstrap() }, quit: { NSApp.terminate(nil) } ) nav.setHotkey = { [weak self] spec in @@ -414,13 +463,23 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, handlePostUpdateStatus(result: result) } - // First-run welcome: auto-open the panel if STACKNUDGE_WELCOMED isn't - // set yet. Brief delay so install.sh's launchctl bounce settles. - // Permission prompts are user-triggered from the welcome screen, - // not auto-fired. - if !nav.welcomed { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in - guard let self, !self.nav.welcomed else { return } + // First-launch detection: if no install artifacts exist on this Mac, + // route the user through the bootstrap wizard before they can use + // anything else. handlePostUpdateStatus took priority above so a + // freshly-installed user upgrading via auto-update doesn't see the + // wizard again. + if !Bootstrap.isInstalled(), nav.mode != .postUpdate { + nav.bootstrapAvailableAgents = Bootstrap.availableAgents() + // Exclude Gemini from the default-selected set — its row is + // info-only (hook wiring is manual), so pre-selecting it would + // mislead the user into thinking we'll wire something. + nav.bootstrapSelectedAgents = Set( + nav.bootstrapAvailableAgents.filter { $0 != .gemini } + ) + nav.bootstrapPhase = .idle + nav.mode = .bootstrap + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + guard let self else { return } NSApp.activate(ignoringOtherApps: true) self.panel.makeKeyAndOrderFront(nil) } @@ -497,6 +556,75 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, hidePanel() } + // MARK: - Bootstrap (first-launch install) + + // Run Bootstrap.install on a background queue, stream progress lines + // into nav.bootstrapLog so the wizard updates live. Switch phase to + // .done on success, .failed on error. + private func runBootstrap() { + let agents = nav.bootstrapSelectedAgents + nav.bootstrapPhase = .installing + nav.bootstrapLog = "" + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + do { + try Bootstrap.install(agents: agents) { line in + DispatchQueue.main.async { + guard let self else { return } + let prefix = self.nav.bootstrapLog.isEmpty ? "" : "\n" + self.nav.bootstrapLog += prefix + line + } + } + DispatchQueue.main.async { [weak self] in + self?.nav.bootstrapPhase = .done + } + } catch { + DispatchQueue.main.async { [weak self] in + self?.nav.bootstrapPhase = .failed( + (error as? LocalizedError)?.errorDescription + ?? error.localizedDescription + ) + } + } + } + } + + // MARK: - Uninstall + + // Switch to the uninstall confirmation view from anywhere in Settings. + private func beginUninstallFlow() { + nav.uninstallPhase = .confirm + nav.uninstallLog = "" + nav.mode = .uninstall + } + + // User confirmed uninstall. Run Bootstrap.uninstall on a background + // queue. The final step (recycle + NSApp.terminate) is dispatched + // from inside Bootstrap.uninstall so the app exits cleanly. + private func runUninstall() { + nav.uninstallPhase = .uninstalling + nav.uninstallLog = "" + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + do { + try Bootstrap.uninstall { line in + DispatchQueue.main.async { + guard let self else { return } + let prefix = self.nav.uninstallLog.isEmpty ? "" : "\n" + self.nav.uninstallLog += prefix + line + } + } + } catch { + DispatchQueue.main.async { [weak self] in + self?.nav.uninstallPhase = .failed( + (error as? LocalizedError)?.errorDescription + ?? error.localizedDescription + ) + } + } + } + } + // MARK: - Quota polling // Fires QuotaProbe on a recurring timer. Cadence varies: 60s while the @@ -533,10 +661,18 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, } } - // Fire a banner when a tier crosses the user's configured threshold — - // once per period (reset when the tier's resets_at advances past the - // prior recorded reset, i.e. a new period started with a fresh budget). - // Master switch on PanelNav silences everything when toggled off. + // Fire a banner each time a tier crosses into a new 5% bucket at or + // above the user's configured threshold. With threshold=80, the user + // sees one alert at 80%, one at 85%, one at 90%, etc. — never more + // than once per bucket. Buckets reset when we detect a sharp drop in + // utilization (period rollover). + // + // Previous logic used the tier's `resets_at` to detect rollover, but + // the 5-hour window's reset is a rolling timestamp that advances on + // every poll, which caused spurious re-fires every few minutes. + private static let quotaBucketSize: Int = 5 + private static let quotaResetDropThreshold: Double = 30 // pp + private func evaluateQuotaThresholds(_ snapshot: QuotaSnapshot) { guard nav.quotaAlertsEnabled else { return } let threshold = Double(nav.quotaAlertThreshold) @@ -548,18 +684,29 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, ("seven_day_sonnet", "Weekly (Sonnet)", snapshot.sevenDaySonnet), ] + let bucketSize = Self.quotaBucketSize + for (name, label, tier) in tiers { guard let tier else { continue } - var state = quotaLastFired[name] ?? (resetsAt: tier.resetsAt, fired: false) - // Reset the fired flag if the period has rolled over. - if let prior = state.resetsAt, let now = tier.resetsAt, now > prior { - state = (resetsAt: now, fired: false) + var state = quotaLastFired[name] ?? (maxBucketFired: 0, peakUtil: 0) + + // Heuristic period-rollover: a >30 pp drop from our running + // peak means a window rolled over (or the user is on a fresh + // billing cycle). Clear the bucket gate so future climbs + // alert again. + if state.peakUtil - tier.utilization > Self.quotaResetDropThreshold { + state = (maxBucketFired: 0, peakUtil: tier.utilization) + } else { + state.peakUtil = max(state.peakUtil, tier.utilization) } - if tier.utilization >= threshold, !state.fired { + + // Current 5% bucket: floor utilization to the nearest 5. + let currentBucket = (Int(tier.utilization) / bucketSize) * bucketSize + if currentBucket >= Int(threshold), currentBucket > state.maxBucketFired { postQuotaBanner(label: label, percent: Int(tier.utilization.rounded()), resetsAt: tier.resetsAt) - state.fired = true + state.maxBucketFired = currentBucket } quotaLastFired[name] = state } @@ -619,11 +766,20 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, center.setNotificationCategories([permCategory, stopCategory]) } - // Post a UNUserNotification when STACKNUDGE_BANNER is enabled. - // Sound is omitted — afplay fires independently in notify.sh so we - // don't double-cue when the macOS banner is also shown. + // Fire the user-facing cues (chime + voice + banner) for an incoming + // event. Audio used to live in notify.sh (`afplay` and `stackvox say` + // forked from the shell hook), but that meant quitting stack-nudge + // didn't stop the bell — bash had already detached the child. Owning + // playback here means Speaker.stopAllAudio() on quit silences us. + // + // Mute-when-focused: when the user is staring at the source editor + // window we suppress the banner + voice, keeping only a subtle chime — + // unless voice is on, in which case we stay fully silent (voice + // replaces the chime in the existing UX contract). + // // If STACKNUDGE_ACTIVATE_IMMEDIATELY is set, focus the source editor - // right away without waiting for the user to click. + // right away without waiting for the user to click; we skip cues + // entirely in that flow since the editor jump is the signal. private func postBannerIfNeeded(_ event: NudgeEvent) { let config = PanelConfig.load() @@ -639,10 +795,93 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, return } + let muted = !event.bypassMute + && config.muteWhenFocused + && isEventSourceFocused(event) + + if muted { + // Source window is frontmost — keep a minimal cue (chime when + // voice is off and sound is on; nothing otherwise, matching + // notify.sh's prior contract). No banner, no voice utterance. + if config.soundEnabled, !config.voiceEnabled, let sound = event.soundName { + Speaker.playSound(named: sound) + } + return + } + + if config.soundEnabled, !config.voiceEnabled, let sound = event.soundName { + Speaker.playSound(named: sound) + } + if config.voiceEnabled, let phrase = event.voiceMessage, !phrase.isEmpty { + Speaker.speak(phrase, voice: config.voiceName, speed: config.voiceSpeed) + } + guard config.bannerEnabled else { return } postBanner(for: event) } + // Returns true if the event's source editor window appears to be the + // user's current focus. Ported from notify.sh's mute_when_focused + // block: match the frontmost app's bundle ID first (cheap), and when + // we have a window title to compare against, confirm via System Events. + private func isEventSourceFocused(_ event: NudgeEvent) -> Bool { + guard let sourceBundle = event.bundleID, + let front = NSWorkspace.shared.frontmostApplication?.bundleIdentifier, + front == sourceBundle + else { return false } + + guard let want = event.windowTitle, !want.isEmpty, + let processName = processName(for: sourceBundle) + else { + // No window title to disambiguate — bundle match alone is + // enough (single-window editors, or the user has just the one + // project open in this app). + return true + } + + let script = """ + tell application "System Events" + tell process "\(processName)" + try + return title of window 1 + end try + end tell + end tell + """ + let p = Process() + p.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + p.arguments = ["-e", script] + let pipe = Pipe() + p.standardOutput = pipe + p.standardError = FileHandle.nullDevice + do { + try p.run() + p.waitUntilExit() + } catch { + return false + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let frontTitle = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return frontTitle == want + } + + // Same bundle-ID → System Events process-name mapping as notify.sh's + // notify_macos(); only the editors/terminals we already special-case + // for click-to-focus need an entry here. + private func processName(for bundleID: String) -> String? { + switch bundleID { + case "com.todesktop.230313mzl4w4u92": return "Cursor" + case "com.microsoft.VSCode": return "Code" + case "dev.zed.Zed": return "Zed" + case "com.googlecode.iterm2": return "iTerm2" + case "dev.warp.Warp-Stable": return "Warp" + case "com.mitchellh.ghostty": return "Ghostty" + case "com.apple.Terminal": return "Terminal" + default: return nil + } + } + // Posts a UNNotificationRequest for an event. Used by postBannerIfNeeded // for the initial fire and by the snooze timer for re-fires. Request // identifier is a fresh UUID each time (macOS replaces by identifier); @@ -830,10 +1069,24 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, } } + // Fired when the user re-opens the app while it's already running — + // double-click from Finder, `open -a StackNudge`, Spotlight, etc. + // LSUIElement apps have no Dock icon, so this is the single entry + // point for "I clicked the app." Show the panel and return false so + // macOS knows we handled it and doesn't spawn a second process. + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { + showPanel() + return false + } + func applicationWillTerminate(_ notification: Notification) { listener?.stop() quotaTimer?.invalidate() quotaTimer = nil + // Stop any in-flight afplay/stackvox children so Quit silences + // audio that was triggered by us — the original bug that motivated + // moving the bell from notify.sh into the app. + Speaker.stopAllAudio() } // MARK: - PanelKeyDelegate @@ -843,23 +1096,6 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, let blockingMods: NSEvent.ModifierFlags = [.control, .option] let cmdOnly = mods.intersection([.command, .control, .option, .shift]) == .command - // Welcome view: only Enter (dismiss) and Esc (hide) are meaningful. - // Swallow everything else so the user can't navigate to a non-existent - // tab strip while welcome is showing. - if !nav.welcomed { - let plain = mods.intersection([.command, .control, .option, .shift]).isEmpty - guard plain else { return true } - switch event.keyCode { - case KeyCode.returnKey, KeyCode.numpadEnter: - nav.dismissWelcome() - case KeyCode.escape: - hidePanel() - default: - break - } - return true - } - // While recording a hotkey, capture the next combo. Arrow keys / Tab // bail out gracefully — otherwise users who entered record mode by // mistake would be stuck on row 0 with all their keypresses swallowed. @@ -909,6 +1145,62 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, } } + // Bootstrap experience: + // .idle: Enter → install, Esc → quit (user opting out) + // .installing: Enter/Esc both no-op (install is running) + // .done: Enter → continue to events, Esc also → continue + // (the install already happened; Esc shouldn't quit) + // .failed: Enter no-op, Esc → quit (user gives up) + if nav.mode == .bootstrap { + let plain = mods.intersection([.command, .control, .option, .shift]).isEmpty + guard plain else { return false } + switch event.keyCode { + case KeyCode.escape: + switch nav.bootstrapPhase { + case .done: + nav.mode = .events + case .idle, .failed: + NSApp.terminate(nil) + case .installing: + break // ignore while running + } + return true + case KeyCode.returnKey, KeyCode.numpadEnter: + switch nav.bootstrapPhase { + case .idle: + nav.actions?.runBootstrap() + case .done: + nav.mode = .events + case .installing, .failed: + break + } + return true + default: + return true // swallow other keys; wizard is single-purpose + } + } + + // Uninstall flow: Enter confirms (when on the confirm step), + // Esc cancels back to settings (only when not mid-run). + if nav.mode == .uninstall { + let plain = mods.intersection([.command, .control, .option, .shift]).isEmpty + guard plain else { return false } + switch event.keyCode { + case KeyCode.escape: + if nav.uninstallPhase == .confirm { + nav.mode = .settings + } + return true + case KeyCode.returnKey, KeyCode.numpadEnter: + if nav.uninstallPhase == .confirm { + nav.actions?.runUninstall() + } + return true + default: + return true + } + } + // Post-update view: Enter or Esc both dismiss to the events tab. // Mirrors WelcomeView's keyboard contract — single-purpose screen, two // keys to exit, no other navigation allowed while it's up. @@ -1226,7 +1518,17 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // MARK: - Setup helpers + // Restore the user's saved position if it still falls inside an attached + // screen; otherwise fall back to top-right of whichever screen the + // cursor's on. Re-arranged monitors or laptops opening lidless can leave + // a saved origin pointing nowhere, so the validation is important. private func positionPanel() { + let savedOrigin = Self.loadSavedPanelOrigin() + if let origin = savedOrigin, + NSScreen.screens.contains(where: { $0.frame.contains(origin) }) { + panel.setFrameOrigin(origin) + return + } let screen = activeScreen() let visible = screen.visibleFrame let size = panel.frame.size @@ -1237,6 +1539,53 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, panel.setFrameOrigin(origin) } + // MARK: - Panel size + origin persistence + + static func loadSavedPanelSize() -> NSSize { + guard let dict = UserDefaults.standard.dictionary(forKey: panelSizeKey), + let w = dict["width"] as? CGFloat, + let h = dict["height"] as? CGFloat + else { return panelDefaultSize } + // Floor at the panel's minimum to defend against pathological values. + return NSSize(width: max(w, 340), height: max(h, 240)) + } + + static func loadSavedPanelOrigin() -> NSPoint? { + guard let dict = UserDefaults.standard.dictionary(forKey: panelOriginKey), + let x = dict["x"] as? CGFloat, + let y = dict["y"] as? CGFloat + else { return nil } + return NSPoint(x: x, y: y) + } + + // Observe NSWindow resize/move so the user's preference is preserved + // across launches, app updates, and reinstalls (UserDefaults lives at + // ~/Library/Preferences/com.stackonehq.stack-nudge.plist). + private func observePanelFrameChanges() { + NotificationCenter.default.addObserver( + forName: NSWindow.didResizeNotification, + object: panel, + queue: .main + ) { [weak self] _ in + guard let panel = self?.panel else { return } + UserDefaults.standard.set( + ["width": panel.frame.width, "height": panel.frame.height], + forKey: Self.panelSizeKey + ) + } + NotificationCenter.default.addObserver( + forName: NSWindow.didMoveNotification, + object: panel, + queue: .main + ) { [weak self] _ in + guard let panel = self?.panel else { return } + UserDefaults.standard.set( + ["x": panel.frame.origin.x, "y": panel.frame.origin.y], + forKey: Self.panelOriginKey + ) + } + } + // Pick the screen the user is most likely looking at: the one under // the mouse cursor. Falls back to NSScreen.main if for some reason we // can't resolve a screen (e.g., headless or screens just being diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index 1ae7a26..46af888 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -19,6 +19,12 @@ enum PanelMode { // after a successful update. Driven by the status file the runner wrote // before the previous instance died. case postUpdate + // First-launch wizard shown when Bootstrap.isInstalled() returns false. + // User picks which detected agents to wire up; Install runs Bootstrap.install. + case bootstrap + // Two-step uninstall reachable from Settings → "Uninstall stack-nudge…". + // Confirmation alert, then progress, then app quits. + case uninstall } // Action callbacks the controller wires into nav so settings rows like @@ -30,6 +36,9 @@ struct SettingsActions { let editPhrases: () -> Void let beginUpdate: () -> Void let runUpdate: () -> Void + let beginUninstall: () -> Void + let runUninstall: () -> Void + let runBootstrap: () -> Void let quit: () -> Void } @@ -46,16 +55,24 @@ final class PanelNav: ObservableObject { @Published var recordingHotkey: Bool = false @Published var hotkeyError: String? @Published var bannerEnabled: Bool = true + @Published var soundEnabled: Bool = true @Published var voiceEnabled: Bool = false @Published var muteWhenFocused: Bool = true @Published var panelPinned: Bool = true - @Published var welcomed: Bool = true // default true; install creates a fresh config without it set @Published var soundStop: String = "Glass" @Published var soundPermission: String = "Ping" @Published var voice: String = "af_aoede" @Published var voiceSpeed: Double = 1.1 @Published var voicesAvailable: [String] = [] @Published var voicesLoading: Bool = true + // Kokoro voice model is fetched on first synthesis (~325 MB to + // ~/.cache/huggingface/). UI hides Voice + Speed rows behind a + // "Download voice model" action until the cache directory appears. + @Published var voiceModelCached: Bool = false + @Published var voiceModelDownloading: Bool = false + @Published var voiceModelProgress: Double = 0 // 0…1; -1 = indeterminate + @Published var voiceModelError: String? + private var voiceModelDownloadProcess: Process? // The latest release tag from GitHub when newer than this bundle's // CFBundleShortVersionString — nil otherwise. Drives both the Settings // tab dot badge and the conditional "Update available" row at the top @@ -86,6 +103,16 @@ final class PanelNav: ObservableObject { // all tiers — banner fires once per period when any tier reaches it. @Published var quotaAlertsEnabled: Bool = true @Published var quotaAlertThreshold: Int = 80 + // First-launch bootstrap wizard state. Populated by PanelController + // on launch when Bootstrap.isInstalled() returns false; drives + // BootstrapView (mode = .bootstrap). + @Published var bootstrapAvailableAgents: [BootstrapAgent] = [] + @Published var bootstrapSelectedAgents: Set = [] + @Published var bootstrapPhase: BootstrapPhase = .idle + @Published var bootstrapLog: String = "" + // Uninstall flow state. Reachable from Settings → "Uninstall stack-nudge…". + @Published var uninstallPhase: UninstallPhase = .confirm + @Published var uninstallLog: String = "" var actions: SettingsActions? // Wired by PanelController so nav can re-register the global hotkey @@ -127,7 +154,7 @@ final class PanelNav: ObservableObject { // when the offset is 1. var updateRowOffset: Int { updateAvailable != nil ? 1 : 0 } - var rowCount: Int { 15 + updateRowOffset } + var rowCount: Int { 17 + updateRowOffset } // Row layout (kept in one place so the controller, view, and indexing // logic all agree on what each row index means). When updateAvailable @@ -138,16 +165,18 @@ final class PanelNav: ObservableObject { // 2 Voice notifications toggle // 3 Mute when focused toggle // 4 Pin panel toggle - // 5 Agent done sound cycle - // 6 Permission sound cycle - // 7 Voice cycle - // 8 Speed cycle - // 9 Quota alerts toggle - // 10 Alert threshold cycle - // 11 Edit phrases… action - // 12 Check permissions… action - // 13 Open config file… action - // 14 Quit panel action + // 5 Sound enabled toggle (gates rows 6 + 7) + // 6 Agent done sound cycle + // 7 Permission sound cycle + // 8 Voice cycle (or "Download model" action) + // 9 Speed cycle + // 10 Quota alerts toggle + // 11 Alert threshold cycle + // 12 Edit phrases… action + // 13 Check permissions… action + // 14 Open config file… action + // 15 Uninstall stack-nudge action + // 16 Quit panel action // MARK: - Disk I/O @@ -155,12 +184,10 @@ final class PanelNav: ObservableObject { let config = ConfigFile.read() hotkeyDisplay = config["STACKNUDGE_PANEL_HOTKEY"] ?? "cmd+opt+n" bannerEnabled = ConfigFile.bool(config, "STACKNUDGE_BANNER", default: true) + soundEnabled = ConfigFile.bool(config, "STACKNUDGE_SOUND", default: true) voiceEnabled = ConfigFile.bool(config, "STACKNUDGE_VOICE", default: false) muteWhenFocused = ConfigFile.bool(config, "STACKNUDGE_MUTE_WHEN_FOCUSED", default: true) panelPinned = ConfigFile.bool(config, "STACKNUDGE_PANEL_PIN", default: true) - // Default false on first run so the welcome view shows. We also write - // STACKNUDGE_WELCOMED=true the first time the user dismisses it. - welcomed = ConfigFile.bool(config, "STACKNUDGE_WELCOMED", default: false) soundStop = config["STACKNUDGE_SOUND_STOP"] ?? "Glass" soundPermission = config["STACKNUDGE_SOUND_PERMISSION"] ?? "Ping" voice = config["STACKNUDGE_VOICE_NAME"] ?? "af_aoede" @@ -172,7 +199,46 @@ final class PanelNav: ObservableObject { quotaAlertThreshold = Self.quotaThresholds.min(by: { abs($0 - rawThreshold) < abs($1 - rawThreshold) }) ?? 80 } + func refreshVoiceModelCached() { + voiceModelCached = Speaker.voiceModelCached() + } + + func startVoiceModelDownload() { + guard !voiceModelDownloading else { return } + voiceModelError = nil + voiceModelProgress = -1 // indeterminate until first tqdm line + voiceModelDownloading = true + voiceModelDownloadProcess = Speaker.downloadVoiceModel( + progress: { [weak self] value in + self?.voiceModelProgress = value + }, + completion: { [weak self] error in + guard let self else { return } + self.voiceModelDownloading = false + self.voiceModelDownloadProcess = nil + if let error { + self.voiceModelError = error.localizedDescription + self.voiceModelCached = Speaker.voiceModelCached() + } else { + self.voiceModelProgress = 1 + self.voiceModelCached = true + // Now that the model is present, populate the voice + // list so the dropdown is ready when SwiftUI re-renders. + self.loadVoices() + } + } + ) + } + + func cancelVoiceModelDownload() { + voiceModelDownloadProcess?.terminate() + } + func loadVoices() { + // Flip into loading state immediately so the UI shows "Loading…" + // instead of a stale "Voices unavailable" while the Process call + // is in flight. Common when called right after a model download. + voicesLoading = true Task.detached(priority: .userInitiated) { let names = Self.runStackvoxVoices() await MainActor.run { [weak self] in @@ -200,23 +266,27 @@ final class PanelNav: ObservableObject { .filter { !$0.isEmpty && !$0.contains(" ") } } - // MARK: - Welcome - - func dismissWelcome() { - welcomed = true - ConfigFile.write(key: "STACKNUDGE_WELCOMED", value: "true") - } - // MARK: - Row movement func selectNextRow() { guard rowCount > 0 else { return } - selectedSettingIndex = (selectedSettingIndex + 1) % rowCount + var next = (selectedSettingIndex + 1) % rowCount + // When the voice model isn't cached we collapse Voice + Speed + // into a single "Download voice model" action at index 7. Index 8 + // (Speed) doesn't render; skip it during keyboard nav. + if !voiceModelCached, next - updateRowOffset == 9 { + next = (next + 1) % rowCount + } + selectedSettingIndex = next } func selectPrevRow() { guard rowCount > 0 else { return } - selectedSettingIndex = (selectedSettingIndex - 1 + rowCount) % rowCount + var prev = (selectedSettingIndex - 1 + rowCount) % rowCount + if !voiceModelCached, prev - updateRowOffset == 9 { + prev = (prev - 1 + rowCount) % rowCount + } + selectedSettingIndex = prev } // MARK: - Cycle / activate @@ -230,10 +300,20 @@ final class PanelNav: ObservableObject { } switch selectedSettingIndex - updateRowOffset { case 0: startRecordingHotkey() - case 11: actions?.editPhrases() - case 12: actions?.checkPermissions() - case 13: actions?.openConfig() - case 14: actions?.quit() + case 8 where !voiceModelCached: + // Pre-download state: index 8 is the "Download voice model" + // action, not a cycle. Enter triggers (or cancels) the + // download. + if voiceModelDownloading { + cancelVoiceModelDownload() + } else { + startVoiceModelDownload() + } + case 12: actions?.editPhrases() + case 13: actions?.checkPermissions() + case 14: actions?.openConfig() + case 15: actions?.beginUninstall() + case 16: actions?.quit() default: applyCycle(forward: true) } } @@ -265,23 +345,33 @@ final class PanelNav: ObservableObject { panelPinned.toggle() ConfigFile.write(key: "STACKNUDGE_PANEL_PIN", value: panelPinned ? "true" : "false") case 5: - soundStop = step(soundStop, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_STOP", preview: true) + soundEnabled.toggle() + ConfigFile.write(key: "STACKNUDGE_SOUND", value: soundEnabled ? "true" : "false") case 6: - soundPermission = step(soundPermission, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_PERMISSION", preview: true) + soundStop = step(soundStop, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_STOP", preview: true) case 7: + soundPermission = step(soundPermission, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_PERMISSION", preview: true) + case 8: + // Pre-download: the row is an action, not a cycle. Treat + // left/right arrow as a trigger so a user discovering the + // row keyboard-only can still start the download. + if !voiceModelCached { + if !voiceModelDownloading { startVoiceModelDownload() } + return + } guard !voicesLoading, !voicesAvailable.isEmpty else { return } voice = step(voice, in: voicesAvailable, forward: forward, key: "STACKNUDGE_VOICE_NAME", preview: false) let phrase = Self.voicePreviewPhrases.randomElement() ?? "Hello." Speaker.speak(phrase, voice: voice, speed: String(format: "%.2f", voiceSpeed)) - case 8: + case 9: let next = forward ? voiceSpeed + Self.speedStep : voiceSpeed - Self.speedStep voiceSpeed = max(Self.speedMin, min(Self.speedMax, (next * 100).rounded() / 100)) ConfigFile.write(key: "STACKNUDGE_VOICE_SPEED", value: String(format: "%.2f", voiceSpeed)) - case 9: + case 10: quotaAlertsEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_QUOTA_ALERTS", value: quotaAlertsEnabled ? "true" : "false") - case 10: + case 11: // Cycle through the static thresholds list. Index wraps in both // directions so the user can dial in either way. let list = Self.quotaThresholds diff --git a/panel/Permissions.swift b/panel/Permissions.swift index fe0f585..33d689d 100644 --- a/panel/Permissions.swift +++ b/panel/Permissions.swift @@ -140,7 +140,7 @@ struct PermissionsView: View { VStack(alignment: .leading, spacing: 4) { Text("Permissions") .font(.title3.weight(.semibold)) - Text("stack-nudge needs these grants to show banners, focus the right window, and send the Enter keystroke when you approve a permission nudge.") + Text("StackNudge needs these grants to show banners, focus the right window, and send the Enter keystroke when you approve a permission nudge.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -247,7 +247,7 @@ final class PermissionsWindowController: NSWindowController { contentRect: NSRect(x: 0, y: 0, width: 480, height: 440), styleMask: [.titled, .closable], backing: .buffered, defer: false) - window.title = "stack-nudge — Permissions" + window.title = "StackNudge — Permissions" window.isReleasedWhenClosed = false window.center() // .moveToActiveSpace follows the user to whatever Space they're on, diff --git a/panel/Settings.swift b/panel/Settings.swift index af4432e..bd5c29c 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -42,25 +42,31 @@ struct SettingsView: View { } section("Sounds") { - row(5 + off, label: "Agent done", kind: .cycle, value: nav.soundStop) - row(6 + off, label: "Permission", kind: .cycle, value: nav.soundPermission) + row(5 + off, label: "Sound enabled", kind: .toggle, value: nav.soundEnabled ? "On" : "Off") + row(6 + off, label: "Agent done", kind: .cycle, value: nav.soundStop) + row(7 + off, label: "Permission", kind: .cycle, value: nav.soundPermission) } section("Voice") { - row(7 + off, label: "Voice", kind: .cycle, value: voiceLabel) - row(8 + off, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed)) + if nav.voiceModelCached { + row(8 + off, label: "Voice", kind: .cycle, value: voiceLabel) + row(9 + off, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed)) + } else { + voiceModelDownloadRow(index: 8 + off) + } } section("Usage") { - row(9 + off, label: "Quota alerts", kind: .toggle, value: nav.quotaAlertsEnabled ? "On" : "Off") - row(10 + off, label: "Alert threshold", kind: .cycle, value: "\(nav.quotaAlertThreshold)%") + row(10 + off, label: "Quota alerts", kind: .toggle, value: nav.quotaAlertsEnabled ? "On" : "Off") + row(11 + off, label: "Alert threshold", kind: .cycle, value: "\(nav.quotaAlertThreshold)%") } section("Actions") { - row(11 + off, label: "Edit phrases…", kind: .action, value: "") - row(12 + off, label: "Check permissions…", kind: .action, value: "") - row(13 + off, label: "Open config file…", kind: .action, value: "") - row(14 + off, label: "Quit panel", kind: .action, value: "") + row(12 + off, label: "Edit phrases…", kind: .action, value: "") + row(13 + off, label: "Check permissions…", kind: .action, value: "") + row(14 + off, label: "Open config file…", kind: .action, value: "") + row(15 + off, label: "Uninstall StackNudge…", kind: .action, value: "") + row(16 + off, label: "Quit panel", kind: .action, value: "") } aboutFooter @@ -91,7 +97,74 @@ struct SettingsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .onAppear { nav.loadFromConfig() - if nav.voicesAvailable.isEmpty { nav.loadVoices() } + nav.refreshVoiceModelCached() + if nav.voiceModelCached, nav.voicesAvailable.isEmpty { + nav.loadVoices() + } + } + } + + // Replaces the Voice + Speed rows when the Kokoro model hasn't been + // fetched yet. Click (or Enter on the row) kicks off + // PanelNav.startVoiceModelDownload(); while in flight the row shows + // a determinate progress bar that flips back to Voice + Speed + // automatically once Speaker.voiceModelCached() flips true. + @ViewBuilder + private func voiceModelDownloadRow(index: Int) -> some View { + let selected = nav.selectedSettingIndex == index + + HStack(spacing: 10) { + Image(systemName: nav.voiceModelDownloading ? "arrow.down.circle.fill" : "arrow.down.circle") + .font(.body) + .foregroundStyle(nav.voiceModelDownloading ? Color.accentColor : Color.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(nav.voiceModelDownloading ? "Downloading voice model…" : "Voice model not downloaded") + .font(.subheadline.weight(.medium)) + if nav.voiceModelDownloading { + if nav.voiceModelProgress < 0 { + ProgressView() + .progressViewStyle(.linear) + .controlSize(.small) + } else { + ProgressView(value: nav.voiceModelProgress) + .progressViewStyle(.linear) + .controlSize(.small) + Text("\(Int((nav.voiceModelProgress * 100).rounded()))% · ~325 MB total") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.tertiary) + } + } else if let err = nav.voiceModelError { + Text(err) + .font(.caption) + .foregroundStyle(.red) + } else { + Text("~325 MB · downloads from GitHub on first run") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + if nav.voiceModelDownloading { + Text("Cancel") + .font(.caption.weight(.medium)) + .foregroundStyle(.red.opacity(0.85)) + } else { + Text("Download") + .font(.caption.weight(.medium)) + .foregroundStyle(Color.accentColor) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(selected ? Color.accentColor.opacity(0.22) : Color.clear) + ) + .contentShape(Rectangle()) + .id(index) + .onTapGesture { + nav.selectedSettingIndex = index + nav.activate() } } @@ -144,7 +217,7 @@ struct SettingsView: View { private var aboutFooter: some View { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" return VStack(spacing: 4) { - Text("stack-nudge v\(version)") + Text("StackNudge v\(version)") .font(.caption2.monospacedDigit()) .foregroundStyle(.tertiary) Button { diff --git a/panel/Speaker.swift b/panel/Speaker.swift index 69ecdf1..ed2bf51 100644 --- a/panel/Speaker.swift +++ b/panel/Speaker.swift @@ -1,4 +1,5 @@ import Foundation +import AppKit // Thin wrapper around the stackvox CLI. Spawns the daemon if its socket // isn't up yet (mirrors notify.sh's auto-start) and falls back to a no-op @@ -18,6 +19,14 @@ enum Speaker { let serve = Process() serve.executableURL = URL(fileURLWithPath: stackvox) serve.arguments = ["serve"] + // Same espeak-ng data-path workaround as the launchd plist — + // the wheel's libespeak-ng.dylib has a CI-build phontab path + // baked in; ESPEAK_DATA_PATH overrides it at runtime. + let venvURL = URL(fileURLWithPath: "\(NSHomeDirectory())/.stack-nudge/venv") + .resolvingSymlinksInPath() + var env = ProcessInfo.processInfo.environment + env.merge(Bootstrap.stackvoxEnv(venvURL: venvURL)) { _, new in new } + serve.environment = env try? serve.run() } @@ -27,6 +36,224 @@ enum Speaker { let say = Process() say.executableURL = URL(fileURLWithPath: stackvox) say.arguments = ["say", "--voice", resolvedVoice, "--speed", resolvedSpeed, text] - try? say.run() + say.standardOutput = FileHandle.nullDevice + say.standardError = FileHandle.nullDevice + say.terminationHandler = { ended in + audioLock.lock(); defer { audioLock.unlock() } + activeAudio.removeAll { $0 === ended } + } + do { + try say.run() + audioLock.lock() + activeAudio.append(say) + audioLock.unlock() + } catch { + // best-effort; stackvox missing → silent fallback + } + } + + // Play a /System/Library/Sounds/*.aiff chime. The afplay path is identical + // to what notify.sh used; we keep it as a Process call (rather than NSSound) + // because afplay terminates on app quit — fixing the "bell keeps ringing + // after quitting stack-nudge" complaint that motivated this move. NSSound + // would play asynchronously without that lifecycle guarantee. + @discardableResult + static func playSound(named name: String) -> Process? { + let path = "/System/Library/Sounds/\(name).aiff" + guard FileManager.default.fileExists(atPath: path) else { return nil } + let p = Process() + p.executableURL = URL(fileURLWithPath: "/usr/bin/afplay") + p.arguments = [path] + p.standardOutput = FileHandle.nullDevice + p.standardError = FileHandle.nullDevice + // Track so applicationWillTerminate can kill any still-playing + // afplay children — the whole point of moving this in-app is that + // quitting the app stops the bell. + p.terminationHandler = { ended in + audioLock.lock(); defer { audioLock.unlock() } + activeAudio.removeAll { $0 === ended } + } + do { + try p.run() + audioLock.lock() + activeAudio.append(p) + audioLock.unlock() + return p + } catch { + return nil + } + } + + // stackvox uses kokoro-onnx (not HF kokoro). The ~340 MB model + voice + // pack live in ~/.cache/stackvox/, downloaded lazily from GitHub + // Releases the first time anything instantiates StackvoxEngine. + // Override via STACKVOX_CACHE_DIR (we don't, but stackvox honours it). + static let voiceModelDir = "\(NSHomeDirectory())/.cache/stackvox" + static let voiceModelFile = "\(voiceModelDir)/kokoro-v1.0.onnx" + static let voicePackFile = "\(voiceModelDir)/voices-v1.0.bin" + + // Expected file sizes for the GitHub release we pin against. Used to + // detect interrupted downloads — stackvox's `_ensure_models` only + // checks file existence, so a half-written file from a previous + // crashed/cancelled run will be left in place and load attempts will + // fail with InvalidProtobuf. We flag those as "not cached" so the + // UI re-offers the download. + // + // Minimums (not exact match) give us tolerance for HEAD updates that + // bump the file slightly while still catching obvious truncations. + private static let voiceModelMinBytes: Int = 320_000_000 // real: ~325 MB + private static let voicePackMinBytes: Int = 27_000_000 // real: ~28 MB + + static func voiceModelCached() -> Bool { + let fm = FileManager.default + let pairs: [(String, Int)] = [ + (voiceModelFile, voiceModelMinBytes), + (voicePackFile, voicePackMinBytes), + ] + for (path, minSize) in pairs { + guard let attrs = try? fm.attributesOfItem(atPath: path), + let size = attrs[.size] as? NSNumber, + size.intValue >= minSize + else { return false } + } + return true + } + + // Force-fetch the kokoro model + voice pack by instantiating + // stackvox's engine in a one-shot Python subprocess. The engine's + // `_ensure_models()` writes the files into ~/.cache/stackvox/ and + // emits its own progress lines on stderr: + // `[stackvox] downloading model 45% (152 MB)` + // No synthesis runs (we never call .speak()), so no audio plays — + // exactly what we want for a pre-warm. + // + // Returns the Process so the caller can terminate() it to cancel. + @discardableResult + static func downloadVoiceModel( + progress: @escaping (Double) -> Void, + completion: @escaping (Error?) -> Void + ) -> Process? { + let venvBin = "\(NSHomeDirectory())/.stack-nudge/venv/bin" + let python = "\(venvBin)/python3" + guard FileManager.default.isExecutableFile(atPath: python) else { + completion(NSError(domain: "Speaker", code: 1, + userInfo: [NSLocalizedDescriptionKey: "python3 not found in venv"])) + return nil + } + + // Defensive cleanup: stackvox's `_ensure_models` only checks + // file existence, so a previously-interrupted download leaves a + // partial file that the engine then fails to load (InvalidProtobuf). + // Always start from a clean slate when the user clicks Download. + let fm = FileManager.default + for path in [voiceModelFile, voicePackFile] { + try? fm.removeItem(atPath: path) + } + + // Minimal script: call the private helper that downloads both + // files into the default cache dir, no engine init, no synthesis. + let script = """ + from stackvox.engine import _ensure_models + from stackvox.paths import cache_dir + _ensure_models(cache_dir()) + """ + + let p = Process() + p.executableURL = URL(fileURLWithPath: python) + p.arguments = ["-u", "-c", script] // -u: unbuffered stdout/stderr + + let venvURL = URL(fileURLWithPath: "\(NSHomeDirectory())/.stack-nudge/venv") + .resolvingSymlinksInPath() + var env = ProcessInfo.processInfo.environment + env.merge(Bootstrap.stackvoxEnv(venvURL: venvURL)) { _, new in new } + env["PYTHONUNBUFFERED"] = "1" + p.environment = env + + let errPipe = Pipe() + p.standardError = errPipe + p.standardOutput = FileHandle.nullDevice + + // stackvox progress format (engine.py:_download_with_progress): + // `\r[stackvox] downloading model 45% (152 MB)` + // One label per file (model + voices); we just take whichever + // percent we last saw — UX is "bar moves forward then resets + // for the second file". + let regex = try? NSRegularExpression(pattern: #"\[stackvox\] downloading \S+\s+(\d{1,3})%"#) + errPipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty, + let chunk = String(data: data, encoding: .utf8) else { return } + for line in chunk.components(separatedBy: CharacterSet(charactersIn: "\r\n")).reversed() { + guard let regex, + let m = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)), + let range = Range(m.range(at: 1), in: line), + let pct = Int(line[range]) + else { continue } + let value = max(0.0, min(1.0, Double(pct) / 100.0)) + DispatchQueue.main.async { progress(value) } + break + } + } + + p.terminationHandler = { ended in + errPipe.fileHandleForReading.readabilityHandler = nil + audioLock.lock(); defer { audioLock.unlock() } + activeAudio.removeAll { $0 === ended } + + DispatchQueue.main.async { + if ended.terminationStatus == 0 { + // Post-download integrity check: stackvox can exit 0 + // with a truncated file if the source server closed + // the stream early. Verify the file is at least its + // expected minimum size before claiming success. + if voiceModelCached() { + completion(nil) + } else { + let onnxSize = (try? FileManager.default + .attributesOfItem(atPath: voiceModelFile))?[.size] as? Int ?? 0 + completion(NSError(domain: "Speaker", code: 3, + userInfo: [NSLocalizedDescriptionKey: + "Download truncated — got \(onnxSize / 1_000_000) MB, expected ~325 MB. Try again."])) + } + } else { + // SIGTERM (15) = user cancelled, not a real error. + if ended.terminationReason == .uncaughtSignal { + completion(NSError(domain: "Speaker", code: 2, + userInfo: [NSLocalizedDescriptionKey: "Cancelled"])) + } else { + completion(NSError(domain: "Speaker", code: Int(ended.terminationStatus), + userInfo: [NSLocalizedDescriptionKey: + "stackvox exited \(ended.terminationStatus)"])) + } + } + } + } + + do { + try p.run() + audioLock.lock() + activeAudio.append(p) + audioLock.unlock() + return p + } catch { + completion(error) + return nil + } + } + + // Kill any in-flight afplay or stackvox children. Called from + // PanelController.applicationWillTerminate so a user-initiated Quit + // also silences the audio that this event chain spawned. + static func stopAllAudio() { + audioLock.lock() + let snapshot = activeAudio + activeAudio.removeAll() + audioLock.unlock() + for p in snapshot where p.isRunning { + p.terminate() + } } } + +private var activeAudio: [Process] = [] +private let audioLock = NSLock() diff --git a/panel/Updater.swift b/panel/Updater.swift index 8073263..d382223 100644 --- a/panel/Updater.swift +++ b/panel/Updater.swift @@ -1,46 +1,44 @@ import AppKit +import CryptoKit import Foundation import SwiftUI -// Phase of the auto-update flow. Parsed from `# STAGE: …` markers written -// by install.sh into the runner log file. Drives the progress UI in -// UpdatingView. +// Phase of the auto-update flow. Drives the progress UI in UpdatingView. +// Updated in-place from Swift as each step of the download/swap pipeline +// completes — no longer driven by a shell runner's STAGE markers. enum UpdatePhase: String { case idle - case cloning - case building - case venv - case launchd - case hooks + case fetching // GET /releases/latest + case downloading // streaming the .tar.gz + case verifying // SHA256 check + case extracting // tar -xzf + case installing // atomic swap + launchctl kickstart case done case failed } extension UpdatePhase { - // Human-readable label shown alongside the spinner. var label: String { switch self { - case .idle: return "Preparing…" - case .cloning: return "Cloning repository…" - case .building: return "Building app…" - case .venv: return "Setting up voice engine…" - case .launchd: return "Registering background agents…" - case .hooks: return "Wiring agent hooks…" - case .done: return "Restarting stack-nudge…" - case .failed: return "Update failed" + case .idle: return "Preparing…" + case .fetching: return "Fetching release…" + case .downloading: return "Downloading update…" + case .verifying: return "Verifying checksum…" + case .extracting: return "Extracting…" + case .installing: return "Installing…" + case .done: return "Restarting stack-nudge…" + case .failed: return "Update failed" } } - // Ordinal position used to render the progress bar. `failed` shares the - // last fillable slot so the bar doesn't suddenly empty on failure. var step: Int { switch self { - case .idle: return 0 - case .cloning: return 1 - case .building: return 2 - case .venv: return 3 - case .launchd: return 4 - case .hooks: return 5 + case .idle: return 0 + case .fetching: return 1 + case .downloading: return 2 + case .verifying: return 3 + case .extracting: return 4 + case .installing: return 5 case .done, .failed: return 6 } } @@ -48,38 +46,35 @@ extension UpdatePhase { static let totalSteps: Int = 6 } -// Drives the click-to-update flow. Spawns install.sh in a detached session -// (via Python `os.setsid()`) so it survives the pkill install.sh runs on the -// running panel mid-flight. Tails the runner log file for live phase + log -// updates that the UI binds to. +// Click-to-update flow: download the latest signed/notarized artifact from +// GitHub Releases, verify its sha256, atomic-swap the existing bundle, kick +// launchd → new bundle starts. No shell runner, no source clone, no rebuild +// — the artifact is already what we want. // -// On completion the runner writes /tmp/stack-nudge-update-status.json so -// the next panel instance (started by launchctl after the swap) can pick up -// where the dying instance left off and show a confirmation toast. +// On success we write /tmp/stack-nudge-update-status.json before triggering +// the relaunch, so the new bundle picks up the "Updated to vX.Y.Z" welcome +// view on its first launch. final class Updater { - // GitHub HTTPS clone URL. SSH (`git@github.com:…`) would require key - // setup; HTTPS works for any user with credential-helper auth (macOS - // keychain or gh CLI integration), which is the org-member default. - static let cloneURL = "https://github.com/StackOneHQ/stack-nudge.git" - - static let logPath = "/tmp/stack-nudge-update.log" static let statusPath = "/tmp/stack-nudge-update-status.json" + static let releasesAPI = URL( + string: "https://api.github.com/repos/StackOneHQ/stack-nudge/releases/latest" + )! + static let releasesGHPath = "repos/StackOneHQ/stack-nudge/releases/latest" private weak var nav: PanelNav? - private var tailHandle: DispatchSourceFileSystemObject? - private var tailFD: Int32 = -1 - private var tailOffset: off_t = 0 - private var logBuffer = "" + private let session: URLSession init(nav: PanelNav) { self.nav = nav + let cfg = URLSessionConfiguration.ephemeral + cfg.timeoutIntervalForRequest = 30 + cfg.timeoutIntervalForResource = 1800 // bundle is ~200 MB; allow up to 30 min + self.session = URLSession(configuration: cfg) } - // Kicks off the install in a detached session. Returns immediately — - // progress flows back to the panel via nav.updaterPhase / nav.updaterLog. - // The runner survives our death (when install.sh pkills us) because of - // setsid; launchctl reload brings a fresh panel up afterwards. + // Kicks off the update on a background queue. Returns immediately; + // progress flows back via nav.updaterPhase / nav.updaterLog. func run() { guard let nav else { return } DispatchQueue.main.async { @@ -88,239 +83,469 @@ final class Updater { nav.mode = .updating } - // Clean slate: any prior log + status file from a previous run. - try? FileManager.default.removeItem(atPath: Self.logPath) + // Wipe any prior status file so the new bundle doesn't see stale + // success/failure from a previous run. try? FileManager.default.removeItem(atPath: Self.statusPath) - FileManager.default.createFile(atPath: Self.logPath, contents: nil) - let runnerPath = "/tmp/stack-nudge-update-runner.sh" - let runnerScript = Self.makeRunnerScript() - try? runnerScript.write(toFile: runnerPath, - atomically: true, encoding: .utf8) - _ = chmod(runnerPath, 0o755) + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self else { return } + do { + try self.performUpdate() + } catch { + self.fail(error) + } + } + } - startTailing() + // MARK: - Pipeline - // Spawn the runner detached via Python's os.setsid + execvp so the - // child process gets its own session and won't be torn down when - // launchd unloads the panel job mid-update. - let task = Process() - task.executableURL = URL(fileURLWithPath: "/usr/bin/env") - // Fork before setsid: Foundation.Process places the spawned child - // in its own process group as the leader, so calling setsid() on - // python directly raises EPERM. We fork once; the child (not a - // pgroup leader) can setsid + exec bash cleanly while the parent - // exits, fully detaching the runner from our session. - task.arguments = [ - "python3", "-c", - """ - import os, sys - pid = os.fork() - if pid == 0: - os.setsid() - os.execvp('bash', ['bash'] + sys.argv[1:]) - else: - os._exit(0) - """, - runnerPath, - ] - task.standardInput = FileHandle.nullDevice - task.standardOutput = FileHandle.nullDevice - // Diagnostic: capture stderr to a file so silent Python errors - // (e.g. PermissionError from setsid()) become visible. Inspect with - // `cat /tmp/stack-nudge-update-spawn.err` after a failed run. - let stderrPath = "/tmp/stack-nudge-update-spawn.err" - try? FileManager.default.removeItem(atPath: stderrPath) - FileManager.default.createFile(atPath: stderrPath, contents: nil) - if let stderrHandle = FileHandle(forWritingAtPath: stderrPath) { - task.standardError = stderrHandle + private func performUpdate() throws { + // 1. Resolve which artifact to download. + setPhase(.fetching) + appendLog("Fetching release manifest…") + let release = try fetchRelease() + appendLog("Latest release: v\(release.version)") + + let arch = currentArch() + guard let asset = release.assets.first(where: { + $0.name.contains("-macos-\(arch).tar.gz") && !$0.name.hasSuffix(".sha256") + }) else { + throw UpdateError.noArtifactForArch(arch: arch) + } + let shaAsset = release.assets.first { + $0.name == "\(asset.name).sha256" + } + appendLog("Selected artifact: \(asset.name) (\(byteCount(asset.size)))") + + // 2. Download the .tar.gz. + setPhase(.downloading) + let tarballURL = try downloadAsset(url: asset.downloadURL, + expectedSize: asset.size) + appendLog("Downloaded to \(tarballURL.path)") + + // 3. Verify checksum if a sidecar was published. + if let sha = shaAsset { + setPhase(.verifying) + try verifyChecksum(tarballURL: tarballURL, + shaAssetURL: sha.downloadURL, + assetName: asset.name) + appendLog("Checksum OK") } else { - task.standardError = FileHandle.nullDevice + appendLog("No .sha256 sidecar — skipping checksum (release isn't yet wired for it)") } - do { - try task.run() - } catch { - DispatchQueue.main.async { - nav.updaterPhase = .failed - nav.updaterLog = "Failed to start updater: \(error.localizedDescription)" + + // 4. Extract. + setPhase(.extracting) + let extractedAppURL = try extractTarball(tarballURL) + appendLog("Extracted to \(extractedAppURL.path)") + + // 5. Strip quarantine xattr so the new bundle doesn't trigger + // Gatekeeper "downloaded from the internet" prompts. + try stripQuarantine(at: extractedAppURL) + + // 6. Atomic swap into ~/Applications/. + setPhase(.installing) + try atomicSwap(extractedAppURL: extractedAppURL) + appendLog("Installed to \(Self.installedAppPath)") + + // 7. Write status file so the next launch surfaces the welcome view. + try writeStatusFile(state: "success", version: release.version, error: nil) + + // 8. Restart launchd → current process dies, new bundle starts. + setPhase(.done) + appendLog("Restarting via launchd…") + try kickstartLaunchd() + + // launchctl kickstart -k will SIGTERM us; if for some reason it + // doesn't, fall back to a self-quit after a brief delay so the + // user isn't stuck staring at "Restarting…" forever. + scheduleAutoQuit() + } + + // MARK: - Release manifest + + private struct ReleaseInfo { + let version: String + let assets: [Asset] + } + private struct Asset { + let name: String + let size: Int + let downloadURL: URL + } + + private func fetchRelease() throws -> ReleaseInfo { + if let json = httpFetchJSON(Self.releasesAPI) { + return try parseRelease(json) + } + // Fall back to gh CLI for private-repo dev cycles (same pattern as + // UpdateChecker's poller). + if let json = ghFetchJSON(Self.releasesGHPath) { + return try parseRelease(json) + } + throw UpdateError.releaseFetchFailed + } + + private func parseRelease(_ json: [String: Any]) throws -> ReleaseInfo { + guard let tag = json["tag_name"] as? String else { + throw UpdateError.malformedReleaseJSON("tag_name missing") + } + let version = tag.hasPrefix("v") ? String(tag.dropFirst()) : tag + let assetsRaw = (json["assets"] as? [[String: Any]]) ?? [] + let assets: [Asset] = assetsRaw.compactMap { + guard let name = $0["name"] as? String, + let size = $0["size"] as? Int, + let urlStr = $0["browser_download_url"] as? String, + let url = URL(string: urlStr) else { return nil } + return Asset(name: name, size: size, downloadURL: url) + } + guard !assets.isEmpty else { + throw UpdateError.malformedReleaseJSON("no assets attached to release") + } + return ReleaseInfo(version: version, assets: assets) + } + + // Unauthenticated GitHub API call (public repo path). Returns nil on + // 404 / 5xx so the caller can fall back to gh. + private func httpFetchJSON(_ url: URL) -> [String: Any]? { + var request = URLRequest(url: url) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("stack-nudge", forHTTPHeaderField: "User-Agent") + let semaphore = DispatchSemaphore(value: 0) + var result: [String: Any]? + session.dataTask(with: request) { data, response, _ in + defer { semaphore.signal() } + let http = response as? HTTPURLResponse + guard let data, http?.statusCode == 200, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return } + result = json + }.resume() + semaphore.wait() + return result + } + + // gh CLI fallback for private repos. Mirrors UpdateChecker.fetchViaGH. + private func ghFetchJSON(_ apiPath: String) -> [String: Any]? { + let candidates = ["/opt/homebrew/bin/gh", "/usr/local/bin/gh", "/usr/bin/gh"] + guard let ghPath = candidates.first(where: { + FileManager.default.isExecutableFile(atPath: $0) + }) else { return nil } + let task = Process() + task.executableURL = URL(fileURLWithPath: ghPath) + task.arguments = ["api", apiPath] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = Pipe() + do { try task.run() } catch { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + guard task.terminationStatus == 0, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + return json + } + + // MARK: - Download + + // Downloads the asset via a regular dataTask + writes to disk. We use + // dataTask (not downloadTask) because URLSession's authenticated-redirect + // handling for GitHub's release CDN is fiddly, and the bundle size at + // 200-ish MB is comfortably in-memory on modern Macs. + private func downloadAsset(url: URL, expectedSize: Int) throws -> URL { + var request = URLRequest(url: url) + request.setValue("application/octet-stream", forHTTPHeaderField: "Accept") + request.setValue("stack-nudge", forHTTPHeaderField: "User-Agent") + + let semaphore = DispatchSemaphore(value: 0) + var resultData: Data? + var taskError: Error? + let task = session.dataTask(with: request) { data, response, error in + defer { semaphore.signal() } + if let error { taskError = error; return } + let http = response as? HTTPURLResponse + guard let data, http?.statusCode == 200 else { + taskError = UpdateError.downloadHTTP(status: http?.statusCode ?? 0) + return } + resultData = data } + task.resume() + semaphore.wait() + + if let taskError { throw taskError } + guard let data = resultData else { + throw UpdateError.downloadHTTP(status: 0) + } + if expectedSize > 0, data.count != expectedSize { + throw UpdateError.downloadSizeMismatch(expected: expectedSize, got: data.count) + } + + // Write to a stable temp path so the rest of the pipeline can run + // tar/xattr against it. + let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("stack-nudge-update-\(UUID().uuidString)", + isDirectory: true) + try FileManager.default.createDirectory(at: tmpDir, + withIntermediateDirectories: true) + let dest = tmpDir.appendingPathComponent("stack-nudge.tar.gz") + try data.write(to: dest) + return dest } - // Build the bash runner. It clones the repo to a fresh tmp dir, runs - // install.sh, and writes a JSON status file at the end. Output and STAGE - // markers go through tee so we get both file persistence and live-tail - // visibility from the panel. - private static func makeRunnerScript() -> String { - let cloneURL = Self.cloneURL - let logPath = Self.logPath - let statusPath = Self.statusPath - return """ - #!/usr/bin/env bash - # stack-nudge auto-updater runner. Spawned in a detached session by - # Updater.swift; survives the pkill install.sh runs on the panel. - set -o pipefail - LOG=\(logPath) - STATUS=\(statusPath) - WORK=$(mktemp -d -t stack-nudge-update) - trap 'rm -rf "$WORK"' EXIT - - write_status() { - local state="$1" version="$2" error_message="$3" - python3 - "$STATUS" "$state" "$version" "$error_message" <<'PY' - import json, sys - path, state, version, err = sys.argv[1:5] - d = {"state": state, "version": version} - if err: - d["error"] = err - with open(path, "w") as f: - json.dump(d, f) - PY - } - - run() { - echo "# STAGE: cloning" - echo "Cloning \(cloneURL) ..." - git clone --depth 1 \(cloneURL) "$WORK" 2>&1 || return 1 - local version - version=$(git -C "$WORK" describe --tags --abbrev=0 2>/dev/null || true) - echo "Cloned $(git -C "$WORK" rev-parse --short HEAD) (tag: ${version:-none})" - cd "$WORK" - bash ./install.sh 2>&1 || return 1 - write_status "success" "${version#v}" "" - return 0 - } - - run > "$LOG" 2>&1 - rc=$? - if [[ $rc -ne 0 ]]; then - # install.sh's failure already in the log; record the failed state - # for the post-swap panel to surface. - write_status "failed" "" "exit code $rc" - fi - exit $rc - - """ - } - - // MARK: - Live log tailing - - // Watches the runner log for writes and parses any new content. Each - // STAGE marker advances nav.updaterPhase; the full content backs the - // expandable "Show output" detail panel. - // - // Uses DispatchSource for filesystem events instead of polling so we - // get near-instant UI updates. Safe to call multiple times — any prior - // tail is torn down and offset is reset, so a re-triggered run() picks - // up from byte 0 of the fresh log file. - private func startTailing() { - // Tear down any prior tail before opening a fresh one. Without this, - // a second run() call would inherit the previous run's offset and - // skip all output (since the truncate makes the new file smaller - // than the saved offset). - tailHandle?.cancel() - tailHandle = nil - if tailFD >= 0 { close(tailFD); tailFD = -1 } - tailOffset = 0 - logBuffer = "" - - tailFD = open(Self.logPath, O_RDONLY) - guard tailFD >= 0 else { return } - let source = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: tailFD, - eventMask: [.write, .extend], - queue: .main - ) - source.setEventHandler { [weak self] in self?.consume() } - source.resume() - tailHandle = source - - // Read whatever's already there in case the first event fires - // after install.sh has already written. - consume() - } - - private func consume() { - guard tailFD >= 0 else { return } - let size = lseek(tailFD, 0, SEEK_END) - guard size > tailOffset else { return } - let toRead = Int(size - tailOffset) - _ = lseek(tailFD, tailOffset, SEEK_SET) - var data = Data(count: toRead) - let bytesRead = data.withUnsafeMutableBytes { buf -> Int in - guard let base = buf.baseAddress else { return 0 } - return read(tailFD, base, toRead) - } - if bytesRead > 0 { - tailOffset += off_t(bytesRead) - if let chunk = String(data: data.prefix(bytesRead), encoding: .utf8) { - logBuffer += chunk - processChunk(chunk) + // MARK: - Verify + + private func verifyChecksum(tarballURL: URL, + shaAssetURL: URL, + assetName: String) throws { + // The .sha256 sidecar is small (~64 bytes); reuse the JSON fetch + // session for it via a plain dataTask. Body format: " ". + var request = URLRequest(url: shaAssetURL) + request.setValue("text/plain", forHTTPHeaderField: "Accept") + request.setValue("stack-nudge", forHTTPHeaderField: "User-Agent") + let semaphore = DispatchSemaphore(value: 0) + var expectedHex: String? + var fetchError: Error? + session.dataTask(with: request) { data, response, error in + defer { semaphore.signal() } + if let error { fetchError = error; return } + let http = response as? HTTPURLResponse + guard let data, http?.statusCode == 200, + let body = String(data: data, encoding: .utf8) else { + fetchError = UpdateError.checksumFetchFailed + return } + // Take the first whitespace-separated token. + expectedHex = body + .split(whereSeparator: { $0.isWhitespace }) + .first + .map(String.init) + }.resume() + semaphore.wait() + if let err = fetchError { throw err } + guard let expectedHex else { throw UpdateError.checksumFetchFailed } + + let data = try Data(contentsOf: tarballURL) + let digest = SHA256.hash(data: data) + let actualHex = digest.map { String(format: "%02x", $0) }.joined() + if actualHex.lowercased() != expectedHex.lowercased() { + throw UpdateError.checksumMismatch(expected: expectedHex, actual: actualHex, + assetName: assetName) + } + } + + // MARK: - Extract + filesystem + + private func extractTarball(_ tarballURL: URL) throws -> URL { + let workDir = tarballURL.deletingLastPathComponent() + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/tar") + task.arguments = ["-xzf", tarballURL.path, "-C", workDir.path] + task.standardOutput = Pipe() + let errPipe = Pipe() + task.standardError = errPipe + try task.run() + task.waitUntilExit() + if task.terminationStatus != 0 { + let err = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8) ?? "" + throw UpdateError.extractFailed(stderr: err) + } + // Find the extracted .app — tarball wraps StackNudge.app at the top level. + let contents = try FileManager.default + .contentsOfDirectory(at: workDir, + includingPropertiesForKeys: nil) + guard let appURL = contents.first(where: { $0.pathExtension == "app" }) else { + throw UpdateError.extractFailed(stderr: "no .app in tarball") } + return appURL } - // Parse STAGE markers (preferred) and natural install.sh output lines - // (fallback) out of newly-arrived log content. The fallback path keeps - // the progress UI accurate when the cloned install.sh is from an older - // release that predates the STAGE markers — otherwise the UI would - // stick on .cloning until the runner finished. - private func processChunk(_ chunk: String) { - for line in chunk.split(separator: "\n", omittingEmptySubsequences: false) { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("# STAGE: ") { - let name = String(trimmed.dropFirst("# STAGE: ".count)) - if let phase = UpdatePhase(rawValue: name) { advance(to: phase) } - } else if let phase = Self.heuristicPhase(for: trimmed) { - advance(to: phase) + private func stripQuarantine(at url: URL) throws { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/xattr") + task.arguments = ["-dr", "com.apple.quarantine", url.path] + task.standardOutput = Pipe() + task.standardError = Pipe() + try task.run() + task.waitUntilExit() + // Ignore exit status — xattr -d is "success" even when the attr + // wasn't set on macOS. Non-zero may be benign. + } + + static let installedAppPath = "\(NSHomeDirectory())/Applications/StackNudge.app" + // Pre-rename path; migrated away on launch via Bootstrap.migrateBundleNameIfNeeded. + // Kept here so the Updater can detect a pre-1.7 install and delete its + // bundle after the new one is in place. + static let legacyInstalledAppPath = "\(NSHomeDirectory())/Applications/stack-nudge.app" + + // Move the existing bundle aside, move the new bundle into place. On + // any error the swap reverts so the user isn't left with a half- + // installed app. The .old bundle stays on disk until the next clean + // shutdown — that's intentional, providing one extra layer of safety. + private func atomicSwap(extractedAppURL: URL) throws { + let fm = FileManager.default + let target = URL(fileURLWithPath: Self.installedAppPath) + let backup = URL(fileURLWithPath: Self.installedAppPath + ".old") + + if fm.fileExists(atPath: backup.path) { + try? fm.removeItem(at: backup) + } + let hadOriginal = fm.fileExists(atPath: target.path) + if hadOriginal { + try fm.moveItem(at: target, to: backup) + } + do { + try fm.moveItem(at: extractedAppURL, to: target) + } catch { + // Best-effort restore. + if hadOriginal { + try? fm.moveItem(at: backup, to: target) } + throw UpdateError.swapFailed(underlying: error) } - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.nav?.updaterLog = self.logBuffer + + // Post-swap: scrub the pre-1.7 bundle name if a migrating user + // still has it sitting in ~/Applications. The plist already points + // at the new path (Bootstrap.migrateBundleNameIfNeeded rewrote it + // on first launch of the new bundle), so the old .app is just + // dead weight at this point. + let legacy = URL(fileURLWithPath: Self.legacyInstalledAppPath) + if fm.fileExists(atPath: legacy.path) { + try? fm.removeItem(at: legacy) } } - // Only ever moves forward — guards against an out-of-order line bumping - // the phase backwards (e.g. seeing the runner's older "Cloning..." echo - // after install.sh has already advanced us). When .done is reached for - // the first time, schedules a graceful self-quit so the freshly-installed - // bundle (relaunched by launchd) takes over without two panels lingering. - private func advance(to phase: UpdatePhase) { + // MARK: - Launchd + + private func kickstartLaunchd() throws { + let uid = getuid() + let task = Process() + task.executableURL = URL(fileURLWithPath: "/bin/launchctl") + task.arguments = ["kickstart", "-k", + "gui/\(uid)/\(Bootstrap.appLabel)"] + task.standardOutput = Pipe() + task.standardError = Pipe() + try task.run() + task.waitUntilExit() + if task.terminationStatus != 0 { + // Not fatal — the kickstart can fail if the agent isn't loaded + // (e.g. fresh dev install). The new bundle is in place; user + // can hit the hotkey to launch it manually next time. + appendLog("launchctl kickstart exited \(task.terminationStatus) (non-fatal)") + } + } + + // MARK: - Status file + + private func writeStatusFile(state: String, version: String, error: String?) throws { + var dict: [String: String] = ["state": state, "version": version] + if let error { dict["error"] = error } + let data = try JSONSerialization.data(withJSONObject: dict) + try data.write(to: URL(fileURLWithPath: Self.statusPath)) + } + + // MARK: - State helpers + + private func setPhase(_ phase: UpdatePhase) { DispatchQueue.main.async { [weak self] in guard let nav = self?.nav else { return } - guard phase.step >= nav.updaterPhase.step else { return } - let firstTimeReachingDone = (phase == .done && nav.updaterPhase != .done) - nav.updaterPhase = phase - if firstTimeReachingDone { - self?.scheduleAutoQuit() + if phase.step >= nav.updaterPhase.step { + nav.updaterPhase = phase } } } - // Quit ~2s after the install finishes so the user can read the "Done" - // confirmation before the panel disappears. install.sh's launchctl - // reload will then own the newly-installed binary's lifecycle. + private func appendLog(_ line: String) { + DispatchQueue.main.async { [weak self] in + guard let nav = self?.nav else { return } + let prefix = nav.updaterLog.isEmpty ? "" : "\n" + nav.updaterLog += prefix + line + } + } + + private func fail(_ error: Error) { + let message = (error as? LocalizedError)?.errorDescription + ?? error.localizedDescription + DispatchQueue.main.async { [weak self] in + self?.nav?.updaterPhase = .failed + let prefix = self?.nav?.updaterLog.isEmpty == false ? "\n" : "" + self?.nav?.updaterLog = (self?.nav?.updaterLog ?? "") + prefix + + "ERROR: " + message + } + // Persist for the next-launch toast so a relaunched panel can + // surface the failure (mostly defensive — we don't expect launchd + // to restart us mid-update, but if it does, we want context). + try? writeStatusFile(state: "failed", version: "", error: message) + } + private func scheduleAutoQuit() { DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { NSApp.terminate(nil) } } - // Recognise canonical install.sh output lines as phase markers. Order - // matters: more specific matches first so "Done!" doesn't get classified - // as something else. Used only when explicit STAGE markers are absent. - private static func heuristicPhase(for line: String) -> UpdatePhase? { - if line.hasPrefix("Done!") { return .done } - if line.contains("registered as launchd agent") { return .launchd } - if line.hasPrefix("Setting up voice engine") { return .venv } - if line.hasPrefix("Building stack-nudge") { return .building } - if line.hasPrefix("Installing stack-nudge") { return .building } - if line.hasPrefix("Detected ") { return .hooks } - return nil + private func currentArch() -> String { + var sysinfo = utsname() + uname(&sysinfo) + let raw = withUnsafePointer(to: &sysinfo.machine) { + $0.withMemoryRebound(to: CChar.self, capacity: 1) { + String(cString: $0) + } + } + // Normalize: uname returns "arm64" or "x86_64" on macOS already. + return raw + } + + private func byteCount(_ bytes: Int) -> String { + let mb = Double(bytes) / 1_048_576 + return String(format: "%.1f MB", mb) } // MARK: - Post-launch status pickup +} + +// Pipeline errors surfaced into nav.updaterLog via fail(). Each case +// carries enough context to debug a CI artifact gone wrong without +// dropping into stderr. +enum UpdateError: LocalizedError { + case releaseFetchFailed + case malformedReleaseJSON(String) + case noArtifactForArch(arch: String) + case downloadHTTP(status: Int) + case downloadSizeMismatch(expected: Int, got: Int) + case checksumFetchFailed + case checksumMismatch(expected: String, actual: String, assetName: String) + case extractFailed(stderr: String) + case swapFailed(underlying: Error) + + var errorDescription: String? { + switch self { + case .releaseFetchFailed: + return "Couldn't reach GitHub Releases (and gh CLI fallback also failed)." + case .malformedReleaseJSON(let detail): + return "Release JSON didn't match expected shape: \(detail)" + case .noArtifactForArch(let arch): + return "No release artifact found for arch '\(arch)'. Expected something like stack-nudge-vX.Y.Z-macos-\(arch).tar.gz." + case .downloadHTTP(let status): + return "Download failed with HTTP status \(status)." + case .downloadSizeMismatch(let expected, let got): + return "Downloaded \(got) bytes, expected \(expected) bytes." + case .checksumFetchFailed: + return "Couldn't fetch the .sha256 sidecar for the release artifact." + case .checksumMismatch(let expected, let actual, let assetName): + return "Checksum mismatch for \(assetName). Expected \(expected), got \(actual)." + case .extractFailed(let stderr): + return "tar failed during extract: \(stderr)" + case .swapFailed(let underlying): + return "Failed to swap installed bundle: \(underlying.localizedDescription)" + } + } +} + +// Wrapper extension so we can keep the existing class members + the +// post-launch status pickup in the original file structure. +extension Updater { // Called from PanelController.applicationDidFinishLaunching to read any // status file the runner left behind during the previous panel's death diff --git a/panel/Welcome.swift b/panel/Welcome.swift deleted file mode 100644 index 2065e03..0000000 --- a/panel/Welcome.swift +++ /dev/null @@ -1,178 +0,0 @@ -import SwiftUI - -// One-time welcome shown the first time the panel opens after install. -// Replaces the tab strip + content until the user presses Enter / clicks -// "Got it"; PanelNav.dismissWelcome() persists the dismissal. -struct WelcomeView: View { - - @ObservedObject var nav: PanelNav - let hotkeyDisplay: String - let onGrantPermissions: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - header - - Text("Notifications for AI coding agents. Banners, voice, and a keyboard-driven panel.") - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - hotkeyHint - - tabsSummary - - permissionsHint - } - .padding(.horizontal, 18) - .padding(.vertical, 18) - .background(ThinScrollers()) - } - - actionBar - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } - - private var header: some View { - HStack(spacing: 10) { - Image(systemName: "bell.badge.fill") - .font(.title3) - .foregroundStyle(Color.accentColor) - Text("Welcome to stack-nudge") - .font(.title3.weight(.semibold)) - Spacer() - } - } - - private var hotkeyHint: some View { - HStack(spacing: 8) { - Text("Press") - .font(.subheadline) - .foregroundStyle(.secondary) - HStack(spacing: 3) { - ForEach(hotkeyDisplay.keyCapTokens, id: \.self) { token in - KeyCapView(symbol: token) - } - } - Text("anytime to open this panel.") - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - - private var tabsSummary: some View { - VStack(alignment: .leading, spacing: 6) { - Text("Three tabs:") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - .padding(.bottom, 2) - - tabRow(systemImage: "bell.fill", - title: "Events", - detail: "Recent nudges; approve and focus with the keyboard") - tabRow(systemImage: "list.bullet.rectangle", - title: "Sessions", - detail: "Running agents you can focus, rename, or terminate") - tabRow(systemImage: "gearshape.fill", - title: "Settings", - detail: "Hotkey, sounds, voice, and more") - } - } - - private var permissionsHint: some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: "lock.shield.fill") - .font(.callout) - .foregroundStyle(Color.orange.opacity(0.8)) - .frame(width: 20, alignment: .center) - .padding(.top, 2) - Text("Notifications and Accessibility permissions are needed for banners and 'Allow' approvals. You can grant them now or later from Settings.") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - - private func tabRow(systemImage: String, title: String, detail: String) -> some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: systemImage) - .font(.callout) - .foregroundStyle(Color.accentColor.opacity(0.8)) - .frame(width: 20, alignment: .center) - .padding(.top, 2) - VStack(alignment: .leading, spacing: 1) { - Text(title).font(.subheadline.weight(.medium)) - Text(detail).font(.caption).foregroundStyle(.secondary) - } - } - } - - private var actionBar: some View { - HStack(spacing: 10) { - Button { - onGrantPermissions() - } label: { - Text("Grant permissions") - .font(.subheadline) - .padding(.horizontal, 12) - .padding(.vertical, 6) - } - .buttonStyle(.plain) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.primary.opacity(0.08)) - ) - - Spacer() - - Button { - nav.dismissWelcome() - } label: { - HStack(spacing: 6) { - Text("Got it") - .font(.subheadline.weight(.medium)) - KeyCapView(symbol: "⏎") - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - } - .buttonStyle(.plain) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.accentColor.opacity(0.25)) - ) - } - .padding(.horizontal, 14) - .padding(.vertical, 9) - .background( - ZStack { - Color.primary.opacity(0.05) - Rectangle() - .fill(Color.primary.opacity(0.1)) - .frame(height: 0.5) - .frame(maxHeight: .infinity, alignment: .top) - } - ) - } -} - -private extension String { - // Split a hotkey spec like "cmd+opt+n" into key cap tokens that match - // the macOS modifier glyphs the rest of the panel uses. - var keyCapTokens: [String] { - split(separator: "+").map { part in - let p = part.trimmingCharacters(in: .whitespaces).lowercased() - switch p { - case "cmd", "command": return "⌘" - case "shift": return "⇧" - case "opt", "alt", "option": return "⌥" - case "ctrl", "control": return "⌃" - default: return p.uppercased() - } - } - } -} diff --git a/panel/entitlements.plist b/panel/entitlements.plist new file mode 100644 index 0000000..48a3226 --- /dev/null +++ b/panel/entitlements.plist @@ -0,0 +1,38 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/uninstall.sh b/uninstall.sh index 705a0c8..42ffb10 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -1,5 +1,14 @@ #!/usr/bin/env bash # stack-nudge uninstaller +# +# macOS users: prefer the in-app uninstall — open the panel via your +# hotkey (default ⌘⌥N), go to Settings, click "Uninstall stack-nudge…". +# It removes the same things this script does (hooks, launchd agents, +# ~/.stack-nudge/) plus trashes the .app. +# +# This script remains as a fallback for Linux/Windows + source-build +# macOS dev cycles, and as a safety net if the in-app uninstall fails +# partway and leaves state behind. set -e @@ -75,7 +84,7 @@ done pkill -f "stack-nudge$" 2>/dev/null || true # Remove app bundles (including old two-binary setup) -for app in stack-nudge.app stack-nudge-panel.app; do +for app in StackNudge.app stack-nudge.app stack-nudge-panel.app; do if [[ -d "$HOME/Applications/$app" ]]; then rm -rf "$HOME/Applications/$app" echo " Removed ~/Applications/$app"