Skip to content

feat: native Mac install — signed releases + self-install/uninstall + artifact auto-update#40

Merged
hiskudin merged 14 commits into
mainfrom
feat/native-mac-install
May 19, 2026
Merged

feat: native Mac install — signed releases + self-install/uninstall + artifact auto-update#40
hiskudin merged 14 commits into
mainfrom
feat/native-mac-install

Conversation

@hiskudin

@hiskudin hiskudin commented May 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Stack-nudge becomes a normal Mac app: download a signed .app from GitHub Releases, drag to ~/Applications/, first-launch wizard does the rest. No shell-script bootstrap on the user's machine, no Xcode CLT requirement, no Python prerequisite.

Replaces three coupled flows:

Before After
git clone && ./install.sh Download tarball, drag .app, first launch self-installs
./uninstall.sh Settings → "Uninstall stack-nudge…"
Auto-updater clones + rebuilds in-place Auto-updater downloads pre-built signed bundle + atomic-swaps

Why

  1. Every release was triggering fresh keychain prompts. Ad-hoc-signed builds have a per-rebuild cdhash, which invalidates Accessibility, Automation, and (since v1.6) Keychain ACLs. Signed releases with a stable Developer ID identity fix this once and for all.
  2. ./install.sh requires bash + git + Xcode CLT + Python on PATH. Faisal hit edge cases in that surface during a routine install; the prebuilt path removes the dependency entirely.
  3. Orphan hooks after uninstall. PR docs: refresh README + fix uninstall regex for quoted hook paths #39 fixed one regex bug; the real fix is putting uninstall behind a Settings button that owns the same code path as install (no script-version-drift risk).
  4. Quitting the app didn't stop the bell. notify.sh fork-detached afplay and stackvox via the shell, so the app had no handle on the children. Quit left audio playing. Moving playback into the app fixes this.

Bundled stackvox (Kokoro voice engine) ships inside the .app (~150–200 MB per arch); the Kokoro voice model itself defers to first-voice-use to keep the bundle tractable.

Phase breakdown (commits)

  1. 7b99e19build.sh: bundle notify.sh + phrases + conf into the .app Resources/, so Bootstrap can copy them out on first launch.
  2. f7efc06Bootstrap.swift core install/uninstall logic. Swift port of install.sh's hook-wiring, launchd-plist-writing, venv-symlinking. JSON via JSONSerialization, plists via PropertyListSerialization, launchctl via Process.
  3. f8d796eBootstrap UI + Settings uninstall row. BootstrapView (first-launch wizard with detected-agents checklist), UninstallView (confirmation + progress + recycle). New .bootstrap / .uninstall PanelMode cases, keyboard handlers, Settings row at index 14.
  4. 63bdde2Updater.swift rewrite. Drops the bash runner / fork-setsid / log-tail / STAGE-marker stack. Replaces with a pure-Swift pipeline: fetch release JSON (gh fallback for private repos) → download arch-appropriate .tar.gz → SHA256 verify → tar extract → strip quarantine → atomic swap → launchctl kickstart.
  5. ad70d62Stackvox venv bundling + recursive signing + hardened-runtime entitlements. build.sh:bundle_venv() downloads python-build-standalone (pinned 20250712 / CPython 3.12.11) + pip-installs stackvox. sign_bundle() recursively signs all .dylib/.so/Mach-O inside Resources/venv/ with hardened runtime + entitlements.plist. Opt-in via STACKNUDGE_BUNDLE_VENV=1 so local dev stays fast.
  6. 33c62baCI: sign + notarize + ship per-arch release artifacts. release.yml matrix on arch, decodes Developer ID from secrets into a temp keychain, sets STACKNUDGE_SIGN_IDENTITY, runs build.sh with venv bundling, submits to xcrun notarytool, staples, uploads as release asset.
  7. 045e70fDocs. README install/uninstall/auto-update sections rewritten. install.sh + uninstall.sh header comments route macOS users to the .app paths; scripts stay for Linux/Windows + source-build devs.
  8. b1e7674Polish. Merge Welcome into Bootstrap (eliminates the redundant second screen on first launch). Rename "Install" → "Set up" and tighten copy. Abbreviate config paths with ~. Make Gemini row informational-only (no hook auto-wire). Make the panel resizable; persist size + origin in UserDefaults so it survives relaunches and auto-updates.
  9. 571ccb3Move audio + voice from notify.sh into the app. notify.sh sheds the afplay calls, the speak_notification helper, and the inline frontmost-window detection (~80 lines). Speaker.playSound(named:) shells afplay with a tracked Process; Speaker.stopAllAudio() is wired into applicationWillTerminate so Quit actually silences the bell. Panel.postBannerIfNeeded now owns chime + voice + mute-when-focused — frontmost-window detection ported via NSWorkspace + System Events. NudgeEvent carries voiceMessage / soundName / bypassMute; the socket DTO and post_to_panel forward them.

Required setup before next release

This PR cannot be tested end-to-end without secrets configured on the repo. Before merging, add these GitHub Actions secrets (Settings → Secrets and variables → Actions → New repository secret):

Secret What it is
MACOS_CERTIFICATE base64-encoded .p12 of your Developer ID Application certificate. base64 -i cert.p12 | pbcopy then paste.
MACOS_CERTIFICATE_PWD The password you used when exporting the .p12 from Keychain Access.
MACOS_KEYCHAIN_PWD Random one-shot password for the temporary CI keychain. Generate with openssl rand -hex 16.
MACOS_NOTARY_API_KEY base64-encoded .p8 from App Store Connect → Users and Access → Keys → "+".
MACOS_NOTARY_API_KEY_ID Key ID shown on App Store Connect (10-char alphanumeric).
MACOS_NOTARY_API_ISSUER_ID Issuer ID, same page (UUID-style).

Without these, the workflow fails on the keychain-setup step with a clear error — no half-installed artifacts.

Verification

The codebase compiles cleanly with the local-build (no venv) path. End-to-end paths that need a release cycle to validate:

  • CI: push a `v1.7.0-rc1` tag, watch release.yml succeed; download the artifact and verify with `codesign -dvv` (cert chain) + `spctl -a -vv` (notarization) + `xcrun stapler validate` (stapled ticket).
  • Fresh install: on a clean Mac profile, download artifact, drag `.app`, launch. Bootstrap wizard appears; pick agents; click Set up. Confirm `/.stack-nudge/notify.sh` exists, `launchctl list | grep stackonehq` shows a running PID, hooks wired in `/.claude/settings.json`.
  • Audio lifecycle: trigger a Stop event with voice on. While the utterance / chime is playing, Quit the app from the menu bar. The bell stops immediately. (Pre-PR behaviour: bell kept ringing because notify.sh had detached the child.)
  • Mute-when-focused: with the source editor frontmost and the project window matching the event's window title, confirm the banner is suppressed and only the chime plays (or nothing, if voice is on). Switching focus to another app re-enables the full cue.
  • Voice activation: Settings → Voice notifications → On. Daemon downloads Kokoro model to `~/.cache/huggingface/` (visible in daemon.log). Subsequent notifications speak instantly.
  • Auto-update: from rc1, push `v1.7.0` (final). Wait for CI. In-app Settings tab shows update dot. Click → confirm → progress UI → app relaunches → post-update welcome view. No keychain re-prompts (cdhash stable across signed releases).
  • Uninstall: Settings → "Uninstall stack-nudge…" → confirm. Progress UI runs through. App quits, `.app` in Trash, `~/.stack-nudge/` gone, hooks gone.
  • Migration: install v1.6.0 (any prior release with the auto-updater code) → trigger auto-update to this release → confirm the new bundle skips the bootstrap wizard (existing install detected) but otherwise behaves correctly.

Trade-offs / known concerns

  • Bundle size ~150-200 MB per artifact (Python + stackvox + ONNX runtime; no model). Downloaded on install + every update. Cursor.app is ~600 MB, ClaudeBar is ~30 MB — we sit between. Could be revisited later via delta updates or lazy voice download if it becomes a problem.
  • Notarization may reject Python-bundled apps on the first try. Apple sometimes complains about unsigned `.so` files inside `site-packages`. The recursive-sign step in build.sh should prevent this, but the rc1 cycle will surface anything we missed. Mitigation: re-run the workflow job after fixing.
  • Hardened-runtime entitlements weaken sandboxing. `allow-jit` + `allow-unsigned-executable-memory` + `disable-library-validation` are required for the bundled Python interpreter. Standard for apps embedding Python.
  • `launchctl kickstart` ordering is sequence-sensitive. Must move-then-kickstart, not kickstart-then-move. Atomic-swap function in Updater.swift handles this.
  • Symlinking `~/.stack-nudge/venv` → bundle path breaks if the user moves the `.app` outside `~/Applications/`. Acceptable trade-off given the size cost of copying 200 MB into `/.stack-nudge/`.
  • Mute-when-focused uses osascript (~50 ms per event). Runs on the main queue inside `postBannerIfNeeded`. Events are rare so this is fine in practice; if it ever becomes a hitch, hop off-main for the System Events call.

Files

17 changed, 2395 insertions, 623 deletions.

New:

  • `panel/Bootstrap.swift` (~900 lines) — install/uninstall logic + BootstrapView + UninstallView
  • `panel/entitlements.plist` — hardened-runtime entitlements for bundled Python

Modified:

  • `panel/Panel.swift` — first-launch detection, mode-routing, key handlers, audio + mute-when-focused, resizable + persisted-frame
  • `panel/PanelNav.swift` — `.bootstrap`/`.uninstall` modes + state + actions
  • `panel/Settings.swift` — Uninstall row at index 14
  • `panel/Updater.swift` — pipeline rewrite
  • `panel/Speaker.swift` — `playSound(named:)` + tracked-child lifecycle + `stopAllAudio()`
  • `panel/EventStore.swift` + `panel/EventListener.swift` — `voiceMessage` / `soundName` / `bypassMute` fields
  • `panel/Config.swift` — voice + mute-when-focused config keys
  • `notify.sh` — strip afplay / speak_notification / inline mute block; forward voice_message + sound_name + bypass_mute on the wire
  • `build.sh` — Resources bundling + venv bundling + recursive signing
  • `.github/workflows/release.yml` — matrix per-arch, sign + notarize + stapler
  • `install.sh` / `uninstall.sh` / `README.md` — deprecation notes + flipped install instructions

🤖 Generated with Claude Code

hiskudin and others added 14 commits May 18, 2026 17:53
The .app needs to ship the runtime payload so Bootstrap.swift (coming
next) can copy it into ~/.stack-nudge/ on first launch — no source
clone needed. Foundation for the native install path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New enum Bootstrap with:
- isInstalled() / availableAgents() — first-launch detection + agent
  discovery (matches install.sh's ~/.claude, ~/.cursor, ~/.gemini
  checks).
- install(agents:progress:) — Swift port of install.sh: copies bundled
  notify.sh + phrases + conf into ~/.stack-nudge/, splices hook
  entries into each selected agent's config (Claude's matcher-group
  shape and Cursor's flat array, both handled), symlinks the bundled
  venv into the canonical path, writes + loads launchd plists for the
  panel + (when venv is present) the voice daemon.
- uninstall(progress:) — reverses everything best-effort: unloads
  launchd, deletes plists, strips stale hook entries via the same
  regex uninstall.sh uses (loosened in #39 to catch quoted commands),
  removes ~/.stack-nudge/, recycles the .app via NSWorkspace and
  terminates.

JSON manipulation uses JSONSerialization; launchd plists via
PropertyListSerialization. No external deps.

The bundled venv path is wired but is a no-op when the .app doesn't
ship with a venv (local dev builds). CI will populate it later in the
phase A workflow.

No UI yet — that lands next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BootstrapView (first-launch wizard):
- Detected agents checklist (Claude Code / Cursor / Gemini); default
  all-selected, click to untick.
- Install button → triggers Bootstrap.install, streams progress lines
  into nav.bootstrapLog so the wizard renders live.
- Done state → Continue button switches to events.
- Failed state → error message + Quit; user re-runs once fixed.

UninstallView (Settings → Uninstall flow):
- Confirmation step with bullet list of what gets removed and what
  stays. Esc cancels back to Settings.
- Confirmed: progress UI streams Bootstrap.uninstall output. Final
  step recycles the bundle + NSApp.terminate inside Bootstrap.

PanelNav:
- New .bootstrap / .uninstall PanelMode cases.
- New state fields: bootstrapAvailableAgents/SelectedAgents/Phase/Log,
  uninstallPhase/Log.
- SettingsActions gets beginUninstall / runUninstall / runBootstrap.
- Settings row layout now 16 rows (was 15); index 14 = Uninstall.

Panel.swift:
- On applicationDidFinishLaunching, if !Bootstrap.isInstalled() and we're
  not currently surfacing the post-update view, set mode = .bootstrap and
  auto-open the panel.
- Routes .bootstrap full-screen (no tab strip; takes priority over the
  welcome screen, which becomes the post-bootstrap secondary nudge).
- Routes .uninstall via the normal switch.
- panelHandlesKey:
  - .bootstrap: Enter → install / Continue, Esc → quit
  - .uninstall: Enter → confirm, Esc → cancel (only when on confirm step)

Settings.swift:
- New "Uninstall stack-nudge…" row at index 14 + off; Quit shifts to 15.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the bash-runner-script + git-clone + install.sh approach with
a pure-Swift download pipeline. The new flow:

  fetching → downloading → verifying → extracting → installing → done

1. GET /releases/latest (with gh CLI fallback for private repos during
   transition).
2. Pick the arch-appropriate .tar.gz asset via uname -m (arm64 /
   x86_64); also locate its .sha256 sidecar if present.
3. URLSession dataTask downloads the tarball to /tmp.
4. SHA256 via CryptoKit, compared against the sidecar.
5. /usr/bin/tar -xzf extracts the .app.
6. /usr/bin/xattr -dr strips com.apple.quarantine.
7. Move existing ~/Applications/stack-nudge.app to .old, move new
   bundle in. Reverts on any error.
8. /bin/launchctl kickstart -k → current process dies, new bundle
   starts. Status file written first so the new bundle's
   consumePostUpdateStatus picks up the "Updated to vX.Y.Z" view.

Dropped from the previous implementation:
- makeRunnerScript and the entire bash runner template
- fork-setsid Python detach (no longer needed; no shell runner to
  survive)
- DispatchSource-based log tailing (no log file to tail; progress is
  streamed directly from Swift now)
- STAGE-marker parsing + heuristic fallback (no install.sh in the loop
  to emit them)

Kept:
- consumePostUpdateStatus() for the welcome view on relaunch
- UpdateConfirmView / UpdatingView / PostUpdateView / MarkdownNotesView
- The scheduled auto-quit safety net in case launchctl kickstart -k
  doesn't terminate us as expected

End-user impact requires a release containing this code AND a CI
workflow shipping signed/notarized artifacts (next commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…titlements

build.sh:
- bundle_venv(): downloads python-build-standalone (pinned 20250712 /
  CPython 3.12.11), extracts into Resources/venv/, pip-installs
  stackvox>=0.4.0 directly into its site-packages, strips __pycache__.
  Per-arch (arm64 / x86_64). Opt-in via STACKNUDGE_BUNDLE_VENV=1 so
  local iteration stays fast; CI sets it for release artifacts.
- sign_bundle(): when a Developer ID is in play, applies
  --options runtime + --entitlements panel/entitlements.plist to the
  outer .app. Recursively signs every .dylib / .so / Mach-O binary
  inside Resources/venv/ first (Apple notarization requires every
  bundled native to carry our signature + entitlements). Ad-hoc path
  is unchanged.

New: panel/entitlements.plist with three opt-ins required by the
bundled Python interpreter:
- com.apple.security.cs.allow-jit
- com.apple.security.cs.allow-unsigned-executable-memory
- com.apple.security.cs.disable-library-validation

Standard set for apps shipping their own Python; matches the pattern
used by Beam, Reflex's app bundles, etc. Documented in the file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the universal-binary lipo + ad-hoc-sign + multi-file tarball
flow with a matrixed per-arch pipeline. Per arch:

1. Stamp tag version into Info.plist.
2. Decode Developer ID cert (.p12) from MACOS_CERTIFICATE secret into a
   temporary keychain; resolve identity into $STACKNUDGE_SIGN_IDENTITY
   so build.sh's sign_bundle path uses it automatically.
3. Run build.sh with STACKNUDGE_BUNDLE_VENV=1 — bundles
   python-build-standalone + stackvox into Resources/venv/ and signs
   everything recursively with hardened runtime + entitlements.
4. xcrun notarytool submit --wait against Apple's notarization service
   (App Store Connect API key from MACOS_NOTARY_API_KEY secret).
5. xcrun stapler staple so the bundle verifies offline.
6. spctl -a -vv sanity check.
7. tar czf — just the .app, nothing else (the bundle is self-contained
   now; install.sh isn't shipped in the artifact).
8. Upload to the release-please-created GitHub Release.

Tarball naming changes:
  Old: stack-nudge-VERSION-universal.tar.gz
  New: stack-nudge-VERSION-macos-{arm64,x86_64}.tar.gz
       + .sha256 sidecars

Auto-updater (Updater.swift) picks the right arch via uname -m.

Required secrets (need to be added on the repo before next release):
  MACOS_CERTIFICATE          (base64 .p12 of Developer ID Application)
  MACOS_CERTIFICATE_PWD      (password used when exporting the .p12)
  MACOS_KEYCHAIN_PWD         (random; one-shot per CI run)
  MACOS_NOTARY_API_KEY       (base64 .p8 from App Store Connect)
  MACOS_NOTARY_API_KEY_ID    (Key ID)
  MACOS_NOTARY_API_ISSUER_ID (Issuer ID)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
README:
- Install section restructured: macOS users get a curl + drag flow
  pointing at the GitHub Releases tarball. Linux/Windows + source-build
  dev paths moved to subsections below.
- Auto-update section rewritten — describes the download/swap pipeline
  (no more clone + install.sh). Notes the 150-200 MB artifact size,
  the model deferred to first-voice-use.
- Uninstall section restructured: in-app Settings → Uninstall is the
  primary path; ./uninstall.sh becomes the fallback.

install.sh / uninstall.sh: header comments now route macOS users to
the .app paths; scripts remain for Linux/Windows + macOS source dev.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UI:
- Drop the separate WelcomeView. The Bootstrap wizard's .done state
  absorbs its content (hotkey hint, four-tab summary, permissions
  hint, Grant Permissions button). New users see one cohesive
  first-launch flow instead of two consecutive "Welcome to
  stack-nudge" screens.
- Rename the primary button "Install" → "Set up". The user already
  installed the app by dragging it; what this step does is
  configuration (hook wiring + launchd registration), not a second
  install.
- Agent rows show `~/.claude/settings.json` instead of the absolute
  `/Users/USER/.claude/settings.json`. Cleaner, less identifying.
- Gemini row is informational only (info icon, no checkbox, "manual
  setup, see README"). Previously it was a checkbox that did nothing
  when ticked — misleading.
- Default-selected agents exclude Gemini for the same reason.
- Esc on the .done phase dismisses to events instead of quitting
  (install already happened; quitting would be surprising).

Panel:
- FloatingPanel is now resizable. Borderless style hides the chrome
  but mouse-drag on edges still works (standard borderless-but-
  resizable pattern). contentMinSize of 340×240 keeps the layout
  from breaking.
- Size + origin persist to UserDefaults
  (~/Library/Preferences/com.stackonehq.stack-nudge.plist). Survives
  launches, app updates, and ~/.stack-nudge/ reinstalls. Saved
  origin is validated against current screens before use, so a
  re-arranged monitor setup falls back to the default top-right
  position.

Cleanup:
- Drop the unused `disabled:` parameter from primaryButton.
- Remove the nav.welcomed field, dismissWelcome(), and
  STACKNUDGE_WELCOMED config read. Bootstrap.isInstalled() is the
  marker now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quitting stack-nudge now silences the bell. Previously notify.sh fork-
detached afplay and stackvox via the shell, so the app had no handle on
the children and Quit left audio playing.

- Speaker.playSound(named:) shells afplay and tracks the Process; the
  twin Speaker.stopAllAudio() is wired into applicationWillTerminate.
- Panel.postBannerIfNeeded now owns the chime + voice + mute-when-focused
  logic. Frontmost-window detection ported from notify.sh via NSWorkspace
  + System Events.
- EventStore.NudgeEvent carries voiceMessage/soundName/bypassMute; the
  socket DTO and notify.sh's post_to_panel forward them.
- notify.sh sheds ~80 lines (afplay calls, speak_notification helper,
  the inline frontmost detection block). Phrase generation still runs in
  bash so curated text flows through the wire payload.
The .app filename governs Finder/Spotlight/Dock display name on macOS;
CFBundleDisplayName alone doesn't override it. Renaming the bundle to
StackNudge.app gives the brand a consistent surface everywhere.

Internal identifiers stay lowercase to preserve user state across the
rename:
  - CFBundleIdentifier         com.stackonehq.stack-nudge
  - Executable                 Contents/MacOS/stack-nudge
  - Dotdir                     ~/.stack-nudge/
  - Launchd labels             com.stackonehq.stack-nudge[-daemon]

Display strings in MenuBar / Permissions / Bootstrap / Updater views
flip to "StackNudge" so the brand reads cleanly in-app too.

Migration for pre-1.7 users: Bootstrap.migrateBundleNameIfNeeded runs
on launch when we're running from the new path. Recycles the legacy
stack-nudge.app sitting in ~/Applications/, then rewrites both launchd
plists' ProgramArguments so launchctl is pointed at the new bundle.
Reload the agents so the change takes effect without a logout cycle.

Tighten Bootstrap.isInstalled() to require notify.sh — the prior "plist
OR notify.sh" check let a partially-installed state (plist written by
migration before install ran) falsely signal "installed" and skipped
the wizard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Kokoro model + voice pack (~325 MB + ~28 MB) live in ~/.cache/stackvox/
and aren't bundled with the .app — kokoro fetches them from GitHub
Releases on first use. Previously this happened silently on the first
voice notification, with no UI feedback and a confusing pause.

The Voice section in Settings now drops Voice + Speed rows behind a
"Download" action when the cache is empty. Clicking it spawns a one-shot
python subprocess that calls stackvox.engine._ensure_models() — pure
file download, no synthesis, so no audio plays. Progress is parsed off
stackvox's own tqdm-style stderr lines and surfaced in a determinate
ProgressView. When the cache fills, the section flips back to Voice +
Speed automatically.

Defensive integrity checks because stackvox's _ensure_models only checks
file existence, not size — an interrupted download leaves a partial file
that subsequent loads fail to parse (InvalidProtobuf). To guard against
this:
  - voiceModelCached() requires >= 320 MB onnx + >= 27 MB voices, not
    just "file exists"
  - Pre-download: wipe any existing files so we always start clean
  - Post-download: verify the size matches before claiming success;
    if not, surface "Download truncated — got X MB, expected ~325 MB"

Audio lifecycle additions for the move from notify.sh:
  - Speaker.playSound(named:) shells afplay and tracks the Process so
    Speaker.stopAllAudio() (called from applicationWillTerminate) can
    actually silence the bell on Quit.
  - speak() enrolls the stackvox-say child the same way.

Voices race fix: loadVoices() now flips voicesLoading=true at the top,
so the UI shows "Loading…" instead of stale "Voices unavailable" while
the Process call is in flight. Common right after a model download.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three independent UX bugs that surfaced during the rename + voice-UI
end-to-end testing.

Panel resizes on tab switch:
  NSHostingView's default sizingOptions on macOS 14+ propagate
  SwiftUI's preferred content size back to the window. Tabs with
  different natural heights (eg the Usage "Loading quota…" empty state
  vs the Events list) were causing the panel to resize mid-session,
  which we then persisted via observePanelFrameChanges. Set sizingOptions
  to [] so SwiftUI's preferred size is purely advisory.

Double-click while running spawns a duplicate process:
  LSUIElement apps have no Dock icon, so the only "click the app to
  open it" path is Finder/Spotlight → open. Without an
  applicationShouldHandleReopen handler, macOS doesn't know we exist as
  a reopenable instance and may launch a second copy. Implement the
  handler: show the panel and return false so macOS treats the reopen
  as fully handled.

Quota alerts re-fire every poll cycle:
  The 5-hour rolling window's resets_at slides forward continuously, so
  the prior "fired flag resets when resets_at advances" logic
  interpreted every poll as a fresh period and re-alerted. Replace with
  5%-bucket gating: alert once at the threshold bucket, once at the next
  5% bucket above, and so on (so 85 → 90 → 95 → 100). Period rollover
  is now detected heuristically via a >30 pp drop from peak utilization,
  which doesn't false-positive on rolling-window timestamp drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the chime only fell off when voice notifications were on
(voice replaces chime). There was no way to silence chimes without
either disabling voice or muting banners — neither captured the user's
actual intent of "I want banners but no noise."

New toggle:
  Settings → Sounds → Sound enabled

Off → chime is skipped in both the muted-when-focused branch and the
normal banner-fire branch of postBannerIfNeeded. Voice still fires when
voice is enabled (it's a separate setting). When On (default), behaviour
is unchanged.

Persisted via STACKNUDGE_SOUND in ~/.stack-nudge/config — matches the
other toggle keys, picked up by both PanelNav (for Settings binding) and
PanelConfig (for the audio gating in Panel.postBannerIfNeeded).

Plumbing:
  - PanelNav row layout +1 (rowCount 16 → 17); Sound enabled lands at
    index 5 as the first row of the Sounds section so it visually gates
    the Agent done / Permission cycles directly below it. All subsequent
    indices (sounds, voice, usage, actions) shift by +1.
  - PanelNav.activate / applyCycle switch cases updated for the new
    layout; selectNextRow/Prev skip-when-voice-not-cached check moves
    from index 8 → 9 (Speed row's new position).
  - Settings.swift section rendering updated with the new row + indices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two unrelated CI failures introduced earlier in this branch:

swift test:
  Package.swift didn't exclude panel/entitlements.plist (added in the
  CI signing commit) so SPM saw it as an unexpected resource and failed
  the build. Excluded alongside Info.plist. Also added ui_improvements.md
  to silence the unhandled-file warning.

shellcheck SC2034:
  When the mute-when-focused logic moved into Swift, win_title was left
  captured-but-unused in notify.sh. The fix: actually pass it through
  to the app — post_to_panel was sending project_name in the
  window_title slot, which Swift's isEventSourceFocused would never
  match against an editor's full window title. project_name was always
  meant to be derived from $PWD via NUDGE_PROJECT, not from arg 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hiskudin hiskudin merged commit 6ec86fd into main May 19, 2026
4 checks passed
@hiskudin hiskudin deleted the feat/native-mac-install branch May 19, 2026 16:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant