From 7b99e1972ab33f3263b89a57af36a7d14cc7f15e Mon Sep 17 00:00:00 2001 From: Hisku Date: Mon, 18 May 2026 17:53:55 +0100 Subject: [PATCH 01/14] build: bundle notify.sh + phrases + conf into Resources/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- build.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/build.sh b/build.sh index cf5d847..4e4e801 100755 --- a/build.sh +++ b/build.sh @@ -28,6 +28,22 @@ 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 + sign_bundle "$app" } From f7efc061c55660c15b6ecf13912d19b724c8d8a4 Mon Sep 17 00:00:00 2001 From: Hisku Date: Mon, 18 May 2026 17:58:32 +0100 Subject: [PATCH 02/14] feat: Bootstrap.swift core install/uninstall logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- build.sh | 1 + panel/Bootstrap.swift | 555 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 556 insertions(+) create mode 100644 panel/Bootstrap.swift diff --git a/build.sh b/build.sh index 4e4e801..1b29f5f 100755 --- a/build.sh +++ b/build.sh @@ -109,6 +109,7 @@ build_app "$APP" "stack-nudge" \ 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/panel/Bootstrap.swift b/panel/Bootstrap.swift new file mode 100644 index 0000000..171afd4 --- /dev/null +++ b/panel/Bootstrap.swift @@ -0,0 +1,555 @@ +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 { + let fm = FileManager.default + if fm.fileExists(atPath: notifyPath) { return true } + if fm.fileExists(atPath: "\(launchAgentsDir)/\(appLabel).plist") { + return true + } + return false + } + + // 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 stack-nudge.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) + } + + // 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) throws { + let plist: [String: Any] = [ + "Label": label, + "ProgramArguments": programArgs, + "RunAtLoad": true, + "KeepAlive": true, + "StandardOutPath": logPath, + "StandardErrorPath": logPath, + ] + 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: - 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)" + } + } +} From f8d796e1c624ce2320f41f8aa704181e8437e0d3 Mon Sep 17 00:00:00 2001 From: Hisku Date: Mon, 18 May 2026 18:04:24 +0100 Subject: [PATCH 03/14] feat: Bootstrap UI + first-launch wizard + Settings uninstall row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- panel/Bootstrap.swift | 350 ++++++++++++++++++++++++++++++++++++++++++ panel/Panel.swift | 154 ++++++++++++++++++- panel/PanelNav.swift | 27 +++- panel/Settings.swift | 9 +- 4 files changed, 531 insertions(+), 9 deletions(-) diff --git a/panel/Bootstrap.swift b/panel/Bootstrap.swift index 171afd4..8bb9c84 100644 --- a/panel/Bootstrap.swift +++ b/panel/Bootstrap.swift @@ -531,6 +531,356 @@ enum Bootstrap { } } +// 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 wizard. Shown automatically when the app +// detects no prior install (Bootstrap.isInstalled() == false). User +// picks which detected agents to wire up and clicks Install — the +// progress UI then streams Bootstrap.install's callbacks until done. +struct BootstrapView: View { + + @ObservedObject var nav: PanelNav + let onInstall: () -> Void + let onQuit: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + header + tagline + if nav.bootstrapPhase == .idle { + agentList + } else { + progress + } + } + .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 tagline: some View { + Text("Notifications for AI coding agents. We'll wire stack-nudge 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) + } + + @ViewBuilder + private var agentList: some View { + if nav.bootstrapAvailableAgents.isEmpty { + Text("No supported agents detected (~/.claude, ~/.cursor, ~/.gemini). Install one and restart stack-nudge 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) + } + } + } + } + + private func agentRow(_ agent: BootstrapAgent) -> some View { + let isSelected = nav.bootstrapSelectedAgents.contains(agent) + return 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(agent == .gemini + ? "Detection only — hook wiring is experimental, see README" + : "Hooks will be added to \(agent.hookConfigPath)") + .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 stack-nudge…").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) { + if nav.bootstrapPhase == .idle || isFailed { + Button { + onQuit() + } label: { + Text("Quit") + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.08)) + ) + } + + Spacer() + + if nav.bootstrapPhase == .idle { + Button { + onInstall() + } label: { + HStack(spacing: 6) { + Text("Install") + .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)) + ) + .disabled(nav.bootstrapSelectedAgents.isEmpty + && !nav.bootstrapAvailableAgents.isEmpty) + } + + if case .done = nav.bootstrapPhase { + Button { + nav.mode = .events + } label: { + HStack(spacing: 6) { + Text("Continue") + .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 var isFailed: Bool { + if case .failed = nav.bootstrapPhase { return true } + return false + } +} + +// 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 stack-nudge?" + : "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("stack-nudge.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 stack-nudge during uninstall", keys: []) + } + } + } +} + // MARK: - Errors enum BootstrapError: LocalizedError { diff --git a/panel/Panel.swift b/panel/Panel.swift index 6c60657..7781bd6 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -73,7 +73,15 @@ struct PanelContentView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - if !nav.welcomed { + if nav.mode == .bootstrap { + // Full-screen first-launch wizard, takes priority over + // welcome (which is a separate post-bootstrap screen). + BootstrapView( + nav: nav, + onInstall: { nav.actions?.runBootstrap() }, + onQuit: { NSApp.terminate(nil) } + ) + } else if !nav.welcomed { WelcomeView(nav: nav, hotkeyDisplay: nav.hotkeyDisplay, onGrantPermissions: onGrantPermissions) @@ -102,7 +110,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() } + ) } } } @@ -390,6 +405,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,6 +432,23 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, handlePostUpdateStatus(result: result) } + // 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() + nav.bootstrapSelectedAgents = Set(nav.bootstrapAvailableAgents) + 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) + } + } + // 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, @@ -497,6 +532,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 @@ -909,6 +1013,52 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, } } + // Bootstrap wizard: Enter triggers install when idle / dismisses + // when done; Esc quits the app entirely. No other key handling — + // the agent checkboxes are click-only for v1. + if nav.mode == .bootstrap { + let plain = mods.intersection([.command, .control, .option, .shift]).isEmpty + guard plain else { return false } + switch event.keyCode { + case KeyCode.escape: + NSApp.terminate(nil) + return true + case KeyCode.returnKey, KeyCode.numpadEnter: + switch nav.bootstrapPhase { + case .idle: + nav.actions?.runBootstrap() + case .done: + nav.mode = .events + case .installing, .failed: + break // running or failed — Enter does nothing + } + 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. diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index 1ae7a26..bf80085 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 } @@ -86,6 +95,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 +146,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 { 16 + updateRowOffset } // Row layout (kept in one place so the controller, view, and indexing // logic all agree on what each row index means). When updateAvailable @@ -147,7 +166,8 @@ final class PanelNav: ObservableObject { // 11 Edit phrases… action // 12 Check permissions… action // 13 Open config file… action - // 14 Quit panel action + // 14 Uninstall stack-nudge action + // 15 Quit panel action // MARK: - Disk I/O @@ -233,7 +253,8 @@ final class PanelNav: ObservableObject { case 11: actions?.editPhrases() case 12: actions?.checkPermissions() case 13: actions?.openConfig() - case 14: actions?.quit() + case 14: actions?.beginUninstall() + case 15: actions?.quit() default: applyCycle(forward: true) } } diff --git a/panel/Settings.swift b/panel/Settings.swift index af4432e..f03f43e 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -57,10 +57,11 @@ struct SettingsView: View { } 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(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: "Uninstall stack-nudge…", kind: .action, value: "") + row(15 + off, label: "Quit panel", kind: .action, value: "") } aboutFooter From 63bdde2e6f9f23ed950c33df230db2f25b4bfe26 Mon Sep 17 00:00:00 2001 From: Hisku Date: Mon, 18 May 2026 18:09:51 +0100 Subject: [PATCH 04/14] feat: Updater downloads + atomic-swaps signed release artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- panel/Updater.swift | 701 ++++++++++++++++++++++++++++---------------- 1 file changed, 456 insertions(+), 245 deletions(-) diff --git a/panel/Updater.swift b/panel/Updater.swift index 8073263..1a29f6c 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,455 @@ 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 stack-nudge.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/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 + } + + // 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)") } } - // 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: - 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 From ad70d625c09cb9dbd9c3e3eea3e5d49f0b20405b Mon Sep 17 00:00:00 2001 From: Hisku Date: Mon, 18 May 2026 18:12:59 +0100 Subject: [PATCH 05/14] build: bundle stackvox venv + recursive signing + hardened-runtime entitlements 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) --- build.sh | 135 +++++++++++++++++++++++++++++++++++++-- panel/entitlements.plist | 38 +++++++++++ 2 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 panel/entitlements.plist diff --git a/build.sh b/build.sh index 1b29f5f..d4d9ed5 100755 --- a/build.sh +++ b/build.sh @@ -44,9 +44,75 @@ build_app() { 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). # @@ -61,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:-}" @@ -77,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 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 + + + From 33c62ba1b45dde952e4303b2fea47ffc5cdd0538 Mon Sep 17 00:00:00 2001 From: Hisku Date: Mon, 18 May 2026 18:15:07 +0100 Subject: [PATCH 06/14] ci: sign + notarize + ship per-arch release artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/release.yml | 152 +++++++++++++++++++++++++--------- 1 file changed, 113 insertions(+), 39 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 950090c..ae4dcf8 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/stack-nudge.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/stack-nudge.app + # Sanity: confirm the stapled bundle passes Gatekeeper's check. + spctl -a -vv build/stack-nudge.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 stack-nudge.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 From 045e70f47b1c7daeae691caf16eb48d7f9094a9c Mon Sep 17 00:00:00 2001 From: Hisku Date: Mon, 18 May 2026 18:17:12 +0100 Subject: [PATCH 07/14] docs: flip primary install path to "download .app from Releases" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 76 +++++++++++++++++++++++++++++++++++++++++++--------- install.sh | 18 ++++++++++++- uninstall.sh | 9 +++++++ 3 files changed, 89 insertions(+), 14 deletions(-) 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/install.sh b/install.sh index f5a7dda..59aa3c1 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 stack-nudge.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 diff --git a/uninstall.sh b/uninstall.sh index 705a0c8..c736bb3 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 From b1e7674878ee20d53f6353d2c9a819413cb77502 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 19 May 2026 10:42:07 +0100 Subject: [PATCH 08/14] polish: merge Welcome into Bootstrap, resizable panel, persisted size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- build.sh | 1 - panel/Bootstrap.swift | 320 +++++++++++++++++++++++++++++++----------- panel/Panel.swift | 142 +++++++++++++------ panel/PanelNav.swift | 11 -- panel/Welcome.swift | 178 ----------------------- 5 files changed, 334 insertions(+), 318 deletions(-) delete mode 100644 panel/Welcome.swift diff --git a/build.sh b/build.sh index d4d9ed5..5bd8684 100755 --- a/build.sh +++ b/build.sh @@ -233,7 +233,6 @@ 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 \ diff --git a/panel/Bootstrap.swift b/panel/Bootstrap.swift index 8bb9c84..c07e388 100644 --- a/panel/Bootstrap.swift +++ b/panel/Bootstrap.swift @@ -550,14 +550,24 @@ enum UninstallPhase: Equatable { // MARK: - Bootstrap view (first-launch wizard) -// Single-screen first-launch wizard. Shown automatically when the app -// detects no prior install (Bootstrap.isInstalled() == false). User -// picks which detected agents to wire up and clicks Install — the -// progress UI then streams Bootstrap.install's callbacks until done. +// 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 { @@ -565,12 +575,7 @@ struct BootstrapView: View { ScrollView { VStack(alignment: .leading, spacing: 16) { header - tagline - if nav.bootstrapPhase == .idle { - agentList - } else { - progress - } + phaseBody } .padding(.horizontal, 18) .padding(.vertical, 18) @@ -583,15 +588,56 @@ struct BootstrapView: View { private var header: some View { HStack(spacing: 10) { - Image(systemName: "bell.badge.fill") + Image(systemName: headerIcon) .font(.title3) - .foregroundStyle(Color.accentColor) - Text("Welcome to stack-nudge") + .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 stack-nudge" + 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 stack-nudge into each agent you've selected below, set up background services, and you'll be ready to go in a few seconds.") .font(.subheadline) @@ -599,6 +645,82 @@ struct BootstrapView: View { .fixedSize(horizontal: false, vertical: true) } + // MARK: - Post-install onboarding content (was Welcome.swift) + + private var completedBlurb: some View { + Text("stack-nudge 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 { @@ -620,30 +742,49 @@ struct BootstrapView: View { } } + @ViewBuilder private func agentRow(_ agent: BootstrapAgent) -> some View { - let isSelected = nav.bootstrapSelectedAgents.contains(agent) - return 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(agent == .gemini - ? "Detection only — hook wiring is experimental, see README" - : "Hooks will be added to \(agent.hookConfigPath)") - .font(.caption) - .foregroundStyle(.secondary) + 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() } - Spacer() - } - .contentShape(Rectangle()) - .onTapGesture { - if isSelected { - nav.bootstrapSelectedAgents.remove(agent) - } else { - nav.bootstrapSelectedAgents.insert(agent) + } 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) + } } } } @@ -689,62 +830,23 @@ struct BootstrapView: View { private var actionBar: some View { HStack(spacing: 10) { + // Left-side button: Quit when idle/failed, Grant Permissions when done. if nav.bootstrapPhase == .idle || isFailed { - Button { - onQuit() - } label: { - Text("Quit") - .font(.subheadline) - .padding(.horizontal, 12) - .padding(.vertical, 6) - } - .buttonStyle(.plain) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.primary.opacity(0.08)) - ) + 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 { - Button { - onInstall() - } label: { - HStack(spacing: 6) { - Text("Install") - .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)) - ) - .disabled(nav.bootstrapSelectedAgents.isEmpty - && !nav.bootstrapAvailableAgents.isEmpty) - } - - if case .done = nav.bootstrapPhase { - Button { - nav.mode = .events - } label: { - HStack(spacing: 6) { - Text("Continue") - .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)) - ) + // 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) @@ -760,12 +862,60 @@ struct BootstrapView: View { ) } + 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. diff --git a/panel/Panel.swift b/panel/Panel.swift index 7781bd6..c1997df 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 } @@ -74,17 +79,15 @@ struct PanelContentView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { if nav.mode == .bootstrap { - // Full-screen first-launch wizard, takes priority over - // welcome (which is a separate post-bootstrap screen). + // 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.welcomed { - WelcomeView(nav: nav, - hotkeyDisplay: nav.hotkeyDisplay, - onGrantPermissions: onGrantPermissions) } else if nav.mode == .postUpdate { // Full-screen takeover, no tab strip — matches welcome's // single-purpose first-launch feel. @@ -352,8 +355,17 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // advances (a new period started, fresh budget). private var quotaLastFired: [String: (resetsAt: Date?, fired: Bool)] = [:] + // 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) + let size = Self.loadSavedPanelSize() + let frame = NSRect(origin: .zero, size: size) panel = FloatingPanel(contentRect: frame) panel.keyDelegate = self @@ -381,6 +393,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, panel.contentView = blur positionPanel() + observePanelFrameChanges() let config = PanelConfig.load() nav.hotkeyDisplay = config.hotkeySpec @@ -439,7 +452,12 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // wizard again. if !Bootstrap.isInstalled(), nav.mode != .postUpdate { nav.bootstrapAvailableAgents = Bootstrap.availableAgents() - nav.bootstrapSelectedAgents = Set(nav.bootstrapAvailableAgents) + // 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 @@ -449,18 +467,6 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, } } - // 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 } - NSApp.activate(ignoringOtherApps: true) - self.panel.makeKeyAndOrderFront(nil) - } - } - // Auto-hide when the panel loses key focus, if pin is off. // Detect "click outside" without polling — NSWindow fires this when // another window or app takes focus. @@ -947,23 +953,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. @@ -1013,15 +1002,25 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, } } - // Bootstrap wizard: Enter triggers install when idle / dismisses - // when done; Esc quits the app entirely. No other key handling — - // the agent checkboxes are click-only for v1. + // 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: - NSApp.terminate(nil) + 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 { @@ -1030,7 +1029,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, case .done: nav.mode = .events case .installing, .failed: - break // running or failed — Enter does nothing + break } return true default: @@ -1376,7 +1375,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 @@ -1387,6 +1396,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 bf80085..e005118 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -58,7 +58,6 @@ final class PanelNav: ObservableObject { @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" @@ -178,9 +177,6 @@ final class PanelNav: ObservableObject { 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" @@ -220,13 +216,6 @@ 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() { 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() - } - } - } -} From 571ccb31ce8fd660c7ce6eca72704f49c5043d1b Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 19 May 2026 11:46:42 +0100 Subject: [PATCH 09/14] feat: move audio + voice from notify.sh into the app 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. --- notify.sh | 115 ++++++++++---------------------------- panel/Config.swift | 8 +++ panel/EventListener.swift | 8 ++- panel/EventStore.swift | 24 +++++++- panel/Panel.swift | 104 ++++++++++++++++++++++++++++++++-- panel/Speaker.swift | 64 ++++++++++++++++++++- 6 files changed, 231 insertions(+), 92 deletions(-) diff --git a/notify.sh b/notify.sh index 991efd6..a8ee30a 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,26 +230,6 @@ 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") @@ -315,6 +281,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 +298,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 +319,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 +335,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 +397,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 +413,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 +420,20 @@ 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" + + post_to_panel "${title}" "${message}" "${bundle_id}" "${project_name}" \ + "${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/Config.swift b/panel/Config.swift index 1db537c..f0b8d5b 100644 --- a/panel/Config.swift +++ b/panel/Config.swift @@ -7,6 +7,10 @@ struct PanelConfig { var hotkeySpec: String = "cmd+opt+n" var bannerEnabled: 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() @@ -25,6 +29,10 @@ struct PanelConfig { case "STACKNUDGE_PANEL_HOTKEY": config.hotkeySpec = value case "STACKNUDGE_BANNER": config.bannerEnabled = 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/Panel.swift b/panel/Panel.swift index c1997df..4655eee 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -729,11 +729,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() @@ -749,10 +758,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; nothing when voice is on, matching notify.sh's + // prior contract). No banner, no voice utterance. + if !config.voiceEnabled, let sound = event.soundName { + Speaker.playSound(named: sound) + } + return + } + + if let sound = event.soundName, !config.voiceEnabled { + 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); @@ -944,6 +1036,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, 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 diff --git a/panel/Speaker.swift b/panel/Speaker.swift index 69ecdf1..51bab14 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 @@ -27,6 +28,67 @@ 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 + } + } + + // 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() From 75a7761eb1f844e1b6c4d70c0e00105d7d3750a0 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 19 May 2026 15:00:53 +0100 Subject: [PATCH 10/14] rebrand: rename bundle to StackNudge.app + migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/ci.yml | 8 +-- .github/workflows/release.yml | 8 +-- build.sh | 4 +- install.sh | 18 +++--- notify.sh | 18 ++++-- panel/Bootstrap.swift | 110 +++++++++++++++++++++++++++++----- panel/Info.plist | 6 +- panel/MenuBar.swift | 8 +-- panel/Permissions.swift | 4 +- panel/Updater.swift | 18 +++++- uninstall.sh | 2 +- 11 files changed, 152 insertions(+), 52 deletions(-) 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 ae4dcf8..be72d75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,15 +104,15 @@ jobs: run: | set -euo pipefail echo "$MACOS_NOTARY_API_KEY" | base64 --decode > /tmp/notary-key.p8 - ditto -c -k --keepParent build/stack-nudge.app /tmp/notarize.zip + 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/stack-nudge.app + xcrun stapler staple build/StackNudge.app # Sanity: confirm the stapled bundle passes Gatekeeper's check. - spctl -a -vv build/stack-nudge.app || true + spctl -a -vv build/StackNudge.app || true rm -f /tmp/notary-key.p8 /tmp/notarize.zip - name: Package ${{ matrix.arch }} @@ -122,7 +122,7 @@ jobs: 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 stack-nudge.app + tar czf "$ARTIFACT" -C build StackNudge.app shasum -a 256 "$ARTIFACT" | awk '{print $1 " " "'"$ARTIFACT"'"}' \ > "$ARTIFACT.sha256" ls -la "$ARTIFACT" "$ARTIFACT.sha256" diff --git a/build.sh b/build.sh index 5bd8684..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" diff --git a/install.sh b/install.sh index 59aa3c1..6704ec7 100755 --- a/install.sh +++ b/install.sh @@ -3,7 +3,7 @@ # # macOS users: prefer the prebuilt .app from GitHub Releases — # https://github.com/StackOneHQ/stack-nudge/releases/latest -# Download the .tar.gz, drag stack-nudge.app to ~/Applications/, and +# 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. # @@ -28,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:" @@ -49,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 @@ -184,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 a8ee30a..a307007 100755 --- a/notify.sh +++ b/notify.sh @@ -230,14 +230,20 @@ nudge_debug() { printf '[stack-nudge] %s\n' "$*" >&2 } -# 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 diff --git a/panel/Bootstrap.swift b/panel/Bootstrap.swift index c07e388..a4a37ab 100644 --- a/panel/Bootstrap.swift +++ b/panel/Bootstrap.swift @@ -87,12 +87,72 @@ enum Bootstrap { // 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 - if fm.fileExists(atPath: notifyPath) { return true } - if fm.fileExists(atPath: "\(launchAgentsDir)/\(appLabel).plist") { - return true + 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 } } - return false + + // 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 @@ -201,7 +261,7 @@ enum Bootstrap { progress("Removing \(installDir)…") try? fm.removeItem(atPath: installDir) - progress("Moving stack-nudge.app to Trash…") + 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. @@ -459,15 +519,30 @@ enum Bootstrap { let logPath = "\(installDir)/daemon.log" try writePlist(label: daemonLabel, programArgs: [stackvox, "serve"], - logPath: logPath) + 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) throws { - let plist: [String: Any] = [ + logPath: String, + env: [String: String] = [:]) throws { + var plist: [String: Any] = [ "Label": label, "ProgramArguments": programArgs, "RunAtLoad": true, @@ -475,6 +550,9 @@ enum Bootstrap { "StandardOutPath": logPath, "StandardErrorPath": logPath, ] + if !env.isEmpty { + plist["EnvironmentVariables"] = env + } let data = try PropertyListSerialization.data( fromPropertyList: plist, format: .xml, @@ -615,7 +693,7 @@ struct BootstrapView: View { private var headerTitle: String { switch nav.bootstrapPhase { - case .idle: return "Welcome to stack-nudge" + case .idle: return "Welcome to StackNudge" case .installing: return "Setting up…" case .done: return "You're all set" case .failed: return "Setup failed" @@ -639,7 +717,7 @@ struct BootstrapView: View { } private var tagline: some View { - Text("Notifications for AI coding agents. We'll wire stack-nudge into each agent you've selected below, set up background services, and you'll be ready to go in a few seconds.") + 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) @@ -648,7 +726,7 @@ struct BootstrapView: View { // MARK: - Post-install onboarding content (was Welcome.swift) private var completedBlurb: some View { - Text("stack-nudge runs from your menu bar. Here's how to use it:") + Text("StackNudge runs from your menu bar. Here's how to use it:") .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -724,7 +802,7 @@ struct BootstrapView: View { @ViewBuilder private var agentList: some View { if nav.bootstrapAvailableAgents.isEmpty { - Text("No supported agents detected (~/.claude, ~/.cursor, ~/.gemini). Install one and restart stack-nudge to wire it up.") + 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) @@ -795,7 +873,7 @@ struct BootstrapView: View { HStack(spacing: 8) { if case .installing = nav.bootstrapPhase { ProgressView().controlSize(.small) - Text("Installing stack-nudge…").font(.subheadline) + Text("Installing StackNudge…").font(.subheadline) } else if case .done = nav.bootstrapPhase { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) @@ -953,7 +1031,7 @@ struct UninstallView: View { .foregroundStyle(.red) VStack(alignment: .leading, spacing: 2) { Text(nav.uninstallPhase == .confirm - ? "Remove stack-nudge?" + ? "Remove StackNudge?" : "Uninstalling…") .font(.headline) if nav.uninstallPhase == .confirm { @@ -975,7 +1053,7 @@ struct UninstallView: View { bullet("Hook entries in your Claude Code / Cursor configs") bullet("Background launchd agents (panel + voice daemon)") bullet("~/.stack-nudge/ (config, phrases, notify.sh)") - bullet("stack-nudge.app (moved to Trash)") + 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) @@ -1025,7 +1103,7 @@ struct UninstallView: View { FooterDivider() FooterHint(label: "Cancel", keys: ["esc"]) } else { - FooterHint(label: "Don't quit stack-nudge during uninstall", keys: []) + FooterHint(label: "Don't quit StackNudge during uninstall", keys: []) } } } 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/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/Updater.swift b/panel/Updater.swift index 1a29f6c..d382223 100644 --- a/panel/Updater.swift +++ b/panel/Updater.swift @@ -351,7 +351,7 @@ final class Updater { encoding: .utf8) ?? "" throw UpdateError.extractFailed(stderr: err) } - // Find the extracted .app — tarball wraps stack-nudge.app at the top level. + // Find the extracted .app — tarball wraps StackNudge.app at the top level. let contents = try FileManager.default .contentsOfDirectory(at: workDir, includingPropertiesForKeys: nil) @@ -373,7 +373,11 @@ final class Updater { // wasn't set on macOS. Non-zero may be benign. } - static let installedAppPath = "\(NSHomeDirectory())/Applications/stack-nudge.app" + 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- @@ -400,6 +404,16 @@ final class Updater { } throw UpdateError.swapFailed(underlying: error) } + + // 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) + } } // MARK: - Launchd diff --git a/uninstall.sh b/uninstall.sh index c736bb3..42ffb10 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -84,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" From 82fc16a6c47194495ff14a060bd381f2db5d8b3a Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 19 May 2026 15:01:40 +0100 Subject: [PATCH 11/14] feat: voice model download UI + integrity checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- panel/PanelNav.swift | 78 +++++++++++++++++++- panel/Settings.swift | 81 +++++++++++++++++++-- panel/Speaker.swift | 165 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 317 insertions(+), 7 deletions(-) diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index e005118..bc49988 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -64,6 +64,14 @@ final class PanelNav: ObservableObject { @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 @@ -188,7 +196,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 @@ -220,12 +267,23 @@ final class PanelNav: ObservableObject { 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 == 8 { + 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 == 8 { + prev = (prev - 1 + rowCount) % rowCount + } + selectedSettingIndex = prev } // MARK: - Cycle / activate @@ -239,6 +297,15 @@ final class PanelNav: ObservableObject { } switch selectedSettingIndex - updateRowOffset { case 0: startRecordingHotkey() + case 7 where !voiceModelCached: + // Pre-download state: index 7 is the "Download voice model" + // action, not a cycle. Enter triggers (or cancels) the + // download. + if voiceModelDownloading { + cancelVoiceModelDownload() + } else { + startVoiceModelDownload() + } case 11: actions?.editPhrases() case 12: actions?.checkPermissions() case 13: actions?.openConfig() @@ -279,6 +346,13 @@ final class PanelNav: ObservableObject { case 6: soundPermission = step(soundPermission, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_PERMISSION", preview: true) case 7: + // 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." diff --git a/panel/Settings.swift b/panel/Settings.swift index f03f43e..e25d34a 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -47,8 +47,12 @@ struct SettingsView: View { } 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(7 + off, label: "Voice", kind: .cycle, value: voiceLabel) + row(8 + off, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed)) + } else { + voiceModelDownloadRow(index: 7 + off) + } } section("Usage") { @@ -60,7 +64,7 @@ struct SettingsView: View { 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: "Uninstall stack-nudge…", kind: .action, value: "") + row(14 + off, label: "Uninstall StackNudge…", kind: .action, value: "") row(15 + off, label: "Quit panel", kind: .action, value: "") } @@ -92,7 +96,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() } } @@ -145,7 +216,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 51bab14..ed2bf51 100644 --- a/panel/Speaker.swift +++ b/panel/Speaker.swift @@ -19,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() } @@ -76,6 +84,163 @@ enum Speaker { } } + // 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. From 91026819bed7758f7843393d9e12d0a7e6e5264b Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 19 May 2026 15:02:11 +0100 Subject: [PATCH 12/14] fix: panel auto-resize, dup-instance on reopen, quota alert spam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- panel/Panel.swift | 69 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/panel/Panel.swift b/panel/Panel.swift index 4655eee..9f0c533 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -353,7 +353,13 @@ 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 @@ -364,6 +370,12 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, private static let panelDefaultSize = NSSize(width: 420, height: 280) func applicationDidFinishLaunching(_ notification: Notification) { + // 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) @@ -382,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([ @@ -643,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) @@ -658,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 } @@ -1032,6 +1069,16 @@ 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() From f0d44a082f984566e84288ed394ab8e6aa35dc35 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 19 May 2026 15:30:05 +0100 Subject: [PATCH 13/14] feat: add Sound toggle in Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- panel/Config.swift | 2 ++ panel/Panel.swift | 8 +++--- panel/PanelNav.swift | 58 ++++++++++++++++++++++++-------------------- panel/Settings.swift | 25 ++++++++++--------- 4 files changed, 51 insertions(+), 42 deletions(-) diff --git a/panel/Config.swift b/panel/Config.swift index f0b8d5b..d5d3719 100644 --- a/panel/Config.swift +++ b/panel/Config.swift @@ -6,6 +6,7 @@ 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 @@ -28,6 +29,7 @@ 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 diff --git a/panel/Panel.swift b/panel/Panel.swift index 9f0c533..d3b50a0 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -801,15 +801,15 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, if muted { // Source window is frontmost — keep a minimal cue (chime when - // voice is off; nothing when voice is on, matching notify.sh's - // prior contract). No banner, no voice utterance. - if !config.voiceEnabled, let sound = event.soundName { + // 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 let sound = event.soundName, !config.voiceEnabled { + if config.soundEnabled, !config.voiceEnabled, let sound = event.soundName { Speaker.playSound(named: sound) } if config.voiceEnabled, let phrase = event.voiceMessage, !phrase.isEmpty { diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index bc49988..46af888 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -55,6 +55,7 @@ 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 @@ -153,7 +154,7 @@ final class PanelNav: ObservableObject { // when the offset is 1. var updateRowOffset: Int { updateAvailable != nil ? 1 : 0 } - var rowCount: Int { 16 + 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 @@ -164,17 +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 Uninstall stack-nudge action - // 15 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 @@ -182,6 +184,7 @@ 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) @@ -271,7 +274,7 @@ final class PanelNav: ObservableObject { // 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 == 8 { + if !voiceModelCached, next - updateRowOffset == 9 { next = (next + 1) % rowCount } selectedSettingIndex = next @@ -280,7 +283,7 @@ final class PanelNav: ObservableObject { func selectPrevRow() { guard rowCount > 0 else { return } var prev = (selectedSettingIndex - 1 + rowCount) % rowCount - if !voiceModelCached, prev - updateRowOffset == 8 { + if !voiceModelCached, prev - updateRowOffset == 9 { prev = (prev - 1 + rowCount) % rowCount } selectedSettingIndex = prev @@ -297,8 +300,8 @@ final class PanelNav: ObservableObject { } switch selectedSettingIndex - updateRowOffset { case 0: startRecordingHotkey() - case 7 where !voiceModelCached: - // Pre-download state: index 7 is the "Download voice model" + 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 { @@ -306,11 +309,11 @@ final class PanelNav: ObservableObject { } else { startVoiceModelDownload() } - case 11: actions?.editPhrases() - case 12: actions?.checkPermissions() - case 13: actions?.openConfig() - case 14: actions?.beginUninstall() - case 15: actions?.quit() + case 12: actions?.editPhrases() + case 13: actions?.checkPermissions() + case 14: actions?.openConfig() + case 15: actions?.beginUninstall() + case 16: actions?.quit() default: applyCycle(forward: true) } } @@ -342,10 +345,13 @@ 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. @@ -357,15 +363,15 @@ final class PanelNav: ObservableObject { 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/Settings.swift b/panel/Settings.swift index e25d34a..bd5c29c 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -42,30 +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") { if nav.voiceModelCached { - row(7 + off, label: "Voice", kind: .cycle, value: voiceLabel) - row(8 + off, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed)) + 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: 7 + off) + 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: "Uninstall StackNudge…", kind: .action, value: "") - row(15 + 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 From d1469be16a86a605ad196260afdf665e7a3644b3 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 19 May 2026 16:26:07 +0100 Subject: [PATCH 14/14] fix(ci): exclude entitlements.plist from SPM + forward win_title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Package.swift | 2 ++ notify.sh | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) 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/notify.sh b/notify.sh index a307007..bd2cee0 100755 --- a/notify.sh +++ b/notify.sh @@ -438,7 +438,11 @@ notify_macos() { local bypass_mute="false" [[ "${EVENT}" == "welcome" ]] && bypass_mute="true" - post_to_panel "${title}" "${message}" "${bundle_id}" "${project_name}" \ + # 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