feat: native Mac install — signed releases + self-install/uninstall + artifact auto-update#40
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stack-nudge becomes a normal Mac app: download a signed
.appfrom 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:
git clone && ./install.sh.app, first launch self-installs./uninstall.shWhy
./install.shrequires 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.afplayand 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)
7b99e19— build.sh: bundle notify.sh + phrases + conf into the .app Resources/, so Bootstrap can copy them out on first launch.f7efc06— Bootstrap.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.f8d796e— Bootstrap UI + Settings uninstall row. BootstrapView (first-launch wizard with detected-agents checklist), UninstallView (confirmation + progress + recycle). New.bootstrap/.uninstallPanelMode cases, keyboard handlers, Settings row at index 14.63bdde2— Updater.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.ad70d62— Stackvox 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 viaSTACKNUDGE_BUNDLE_VENV=1so local dev stays fast.33c62ba— CI: sign + notarize + ship per-arch release artifacts. release.yml matrix on arch, decodes Developer ID from secrets into a temp keychain, setsSTACKNUDGE_SIGN_IDENTITY, runs build.sh with venv bundling, submits to xcrun notarytool, staples, uploads as release asset.045e70f— Docs. 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.b1e7674— Polish. 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.571ccb3— Move audio + voice from notify.sh into the app. notify.sh sheds theafplaycalls, thespeak_notificationhelper, and the inline frontmost-window detection (~80 lines).Speaker.playSound(named:)shellsafplaywith a trackedProcess;Speaker.stopAllAudio()is wired intoapplicationWillTerminateso Quit actually silences the bell.Panel.postBannerIfNeedednow owns chime + voice + mute-when-focused — frontmost-window detection ported via NSWorkspace + System Events.NudgeEventcarriesvoiceMessage/soundName/bypassMute; the socket DTO andpost_to_panelforward 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):
MACOS_CERTIFICATE.p12of your Developer ID Application certificate.base64 -i cert.p12 | pbcopythen paste.MACOS_CERTIFICATE_PWD.p12from Keychain Access.MACOS_KEYCHAIN_PWDopenssl rand -hex 16.MACOS_NOTARY_API_KEY.p8from App Store Connect → Users and Access → Keys → "+".MACOS_NOTARY_API_KEY_IDMACOS_NOTARY_API_ISSUER_IDWithout 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:
/.stack-nudge/notify.sh` exists, `launchctl list | grep stackonehq` shows a running PID, hooks wired in `/.claude/settings.json`.Trade-offs / known concerns
200 MB into `/.stack-nudge/`.Files
17 changed, 2395 insertions, 623 deletions.
New:
Modified:
🤖 Generated with Claude Code