diff --git a/README.md b/README.md index 4a230ca..bc6e30b 100644 --- a/README.md +++ b/README.md @@ -131,11 +131,7 @@ Add that to your shell profile. If you'd rather not click banners with the mouse, stack-nudge runs a small floating panel that you summon with a hotkey. It has four tabs — **Events**, **Sessions**, **Usage**, and **Settings** — and is fully keyboard-driven. -The panel is installed and registered as a launchd agent by `./install.sh` — no opt-in needed. To run quietly without macOS banners (panel-only): - -```bash -STACKNUDGE_BANNER=false # optional — suppress macOS banners when using the panel -``` +The panel is installed and registered as a launchd agent by `./install.sh` — no opt-in needed. To run quietly without macOS banners, toggle **Settings → Banner notifications** off (panel-only mode). Default hotkey is `cmd+opt+n`. Hit it from anywhere to summon the panel; hit it again while focused to hide. Switch tabs with `Cmd+1` (Events), `Cmd+2` (Sessions), `Cmd+3` (Usage), `Cmd+4` (Settings) — or click them. Banner and panel can run together, alone, or both off — the sound and voice still fire as passive signals. @@ -186,7 +182,7 @@ Bars are color-coded: green below 50%, yellow 50–80%, red 80%+. Reset times sh Data is fetched from the same endpoint Claude Code's own statusline uses (`/api/oauth/usage`), reading your OAuth token from the macOS Keychain. **The first time stack-nudge polls you'll see a keychain dialog — click "Always Allow"** to grant access (one-time, per release). -Polls every 60 seconds while the panel is visible, or every 5 minutes by default in the background (configurable via Settings → Usage → "Poll frequency"; underlying key `STACKNUDGE_USAGE_POLL_MIN`). On the Usage tab: `r` triggers a manual sync, `p` pauses/resumes the poller. +Polls every 60 seconds while the panel is visible, or every 5 minutes by default in the background (configurable via Settings → Usage → "Poll frequency"). On the Usage tab: `r` triggers a manual sync, `p` pauses/resumes the poller. #### Threshold-crossing notifications @@ -207,7 +203,7 @@ Independent of quota: stack-nudge can also fire a banner when an individual Clau #### Settings tab -Reachable from the tab strip or `Cmd+4`. Keyboard-driven rows for hotkey, behavior toggles (banner, mute when focused, pin panel, launch at login), sound picks (with preview-on-cycle), voice notifications + picker + speed (with preview-on-cycle using a random conversational phrase), usage config (quota tracking + alerts + threshold + poll frequency + context alert threshold), and action rows (edit phrases, check permissions, open config file, view release notes, check for updates, uninstall, quit). +Reachable from the tab strip or `Cmd+4`. Keyboard-driven rows for hotkey, behavior toggles (banner, mute when focused, pin panel, launch at login), widget (corner, mascot picker, opacity), sound picks (with preview-on-cycle), voice notifications + picker + speed (with preview-on-cycle using a random conversational phrase), usage config (quota tracking + alerts + threshold + poll frequency + context alert threshold + show-remaining), and action rows (edit phrases, check permissions, open config file, view release notes, check for updates, uninstall, quit). | Key | Action | |-----|--------| @@ -216,9 +212,9 @@ Reachable from the tab strip or `Cmd+4`. Keyboard-driven rows for hotkey, behavi | `⏎` | Activate (toggles flip, action rows fire, hotkey row records a new combo) | | `Esc` | Back to events | -The hotkey row records live: press `⏎` on it, press the new combo, and stack-nudge re-registers the global hotkey and writes it to config. If the combo is already grabbed by another app, the previous one stays and an inline error explains why. +The hotkey row records live: press `⏎` on it, press the new combo, and stack-nudge re-registers the global hotkey. If the combo is already grabbed by another app, the previous one stays and an inline error explains why. -stack-nudge also watches `~/.stack-nudge/config` for external edits, so changes you make via "Open config file…" or another editor flow back into the running panel without a restart. +All Settings choices persist to `~/.stack-nudge/config` (a `KEY=value` text file). You don't need to edit it directly — Settings is the source of truth — but it's there for backup/sync or scripted setup. ### Menu bar (macOS) @@ -227,14 +223,14 @@ When the panel daemon is running, a bell icon appears in your menu bar. The same | Item | What it does | |------|--------------| | `Hotkey · …` | Shows your current hotkey (info only) | -| `Show banners` | Toggles `STACKNUDGE_BANNER`. Enabling fires a confirmation banner. | -| `Voice notifications` | Toggles `STACKNUDGE_VOICE`. Enabling speaks *"Voice notifications enabled"*. | +| `Show banners` | Toggles macOS banner notifications. Enabling fires a confirmation banner. | +| `Voice notifications` | Toggles spoken notifications. Enabling speaks *"Voice notifications enabled"*. | | `Show panel` | Brings the floating panel up (handy when no events are queued) | | `Check permissions…` | Opens the permissions checker (see below) | | `Open config file…` | Opens `~/.stack-nudge/config` in your default editor | | `Quit stack-nudge panel` | Exits the daemon | -Toggles re-read the live config every time the menu opens, so changes you make to `~/.stack-nudge/config` directly stay in sync. Banner and voice changes take effect immediately for the next nudge — no daemon restart needed. +Menu changes take effect immediately for the next nudge — no daemon restart needed. ### Permissions (macOS) @@ -287,15 +283,11 @@ Per-pool customisations are stored in `~/.stack-nudge/phrases.user.json` and mer stack-nudge uses [stackvox](https://github.com/StackOneHQ/stackvox), an offline Kokoro-82M TTS engine that speaks notifications aloud with ~13 ms latency. `./install.sh` pip-installs it from PyPI into an isolated venv at `~/.stack-nudge/venv` — no separate setup needed. -**Enable in your config** (`~/.stack-nudge/config`): - -```bash -STACKNUDGE_VOICE=true -``` +**Enable** via Settings → Voice → "Voice notifications". The voice daemon starts automatically on first notification and is registered as a login item so it stays running across reboots. -Voice fires whenever `STACKNUDGE_VOICE=true`, alongside the banner and panel surfaces. The frontmost-suppression check still applies — when the source window is already focused, sound, banner, panel post, *and* voice are all suppressed (you don't need a nudge for the thing you're looking at). +Voice fires alongside the banner and panel surfaces. The frontmost-suppression check still applies — when the source window is already focused, sound, banner, panel post, *and* voice are all suppressed (you don't need a nudge for the thing you're looking at). When voice is enabled, the chime is suppressed automatically — voice replaces sound rather than playing alongside it. @@ -308,12 +300,7 @@ Voice messages are picked at random from per-event phrase pools and labelled wit Phrase pools live in `~/.stack-nudge/phrases/` — `en.sh`, `fr.sh`, `hi.sh`, `it.sh`, `pt.sh`. The right pool is picked from the configured voice's prefix (`af_*`/`am_*`/`bf_*`/`bm_*` → en, `ff_*` → fr, `hf_*`/`hm_*` → hi, `if_*`/`im_*` → it, `pf_*`/`pm_*` → pt) so a French voice speaks French phrasing. -Optional tuning (also in `~/.stack-nudge/config`): - -```bash -STACKNUDGE_VOICE_NAME=af_heart # voice ID (run `~/.stack-nudge/venv/bin/stackvox voices` for the full list) -STACKNUDGE_VOICE_SPEED=1.1 # playback speed (1.0 = normal) -``` +Tune via Settings → Voice → "Voice" (cycle voices with preview) and "Speed" (1.0 = normal). ## Sounds @@ -322,14 +309,7 @@ STACKNUDGE_VOICE_SPEED=1.1 # playback speed (1.0 = normal) | Agent done | `Glass.aiff` | freedesktop bell | 800 Hz beep | | Waiting for approval | `Ping.aiff` | freedesktop bell | 1200 Hz beep | -Any file from `/System/Library/Sounds/` works on macOS: Basso, Blow, Bottle, Frog, Funk, Glass, Hero, Morse, Ping, Pop, Purr, Sosumi, Submarine, Tink. Override per-event in `~/.stack-nudge/config`: - -```bash -STACKNUDGE_SOUND_STOP=Glass -STACKNUDGE_SOUND_PERMISSION=Ping -``` - -The Settings tab exposes the same picks with audio preview on each change. +Any file from `/System/Library/Sounds/` works on macOS: Basso, Blow, Bottle, Frog, Funk, Glass, Hero, Morse, Ping, Pop, Purr, Sosumi, Submarine, Tink. Override per-event via Settings → Sounds → "Agent done" / "Permission" (cycle plays a preview on each step). ## Uninstall diff --git a/panel/Panel.swift b/panel/Panel.swift index cfd3415..2045f99 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -267,8 +267,8 @@ struct PanelContentView: View { private var footer: some View { PageFooter { if store.events.isEmpty { - FooterHint(label: "Compact", keys: ["M"]) - FooterHint(label: "Hide", keys: ["Esc"]) + if nav.compactMode { FooterHint(label: "Compact", keys: ["M"]) } + FooterHint(label: "Hide", keys: ["Esc"]) } else { if let primary = primaryActionLabel { FooterHint(label: primary, keys: ["⏎"], primary: true) @@ -282,8 +282,8 @@ struct PanelContentView: View { FooterHint(label: "Snooze", keys: ["S"]) .opacity(snoozeEnabled ? 1.0 : 0.35) FooterHint(label: "Dismiss", keys: ["⌫"]) - FooterHint(label: "Compact", keys: ["M"]) - FooterHint(label: "Hide", keys: ["Esc"]) + if nav.compactMode { FooterHint(label: "Compact", keys: ["M"]) } + FooterHint(label: "Hide", keys: ["Esc"]) } } } @@ -656,6 +656,12 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, updater = Updater(nav: nav) startQuotaPolling() + // The pill (CompactView) reads sessions.sessions for the busy/idle + // headline and mascot state, so polling has to run as soon as the + // app is up — not gated on the Sessions tab being visible. Sessions + // view still calls startPolling on appear (idempotent), which keeps + // it polling even if some future code stops the timer. + sessions.startPolling() // Whenever SessionStore re-polls (~every 3s while polling, on // panel-becomes-visible otherwise), refresh transcript stats for @@ -788,14 +794,23 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, private func handlePostUpdateStatus(result: (state: String, version: String, error: String?)) { switch result.state { case "success": - nav.postUpdateVersion = result.version.isEmpty ? "?" : result.version - nav.postUpdateNotes = nil - nav.mode = .postUpdate - // Expand out of the pill so the changelog renders in the full - // panel rather than getting clipped into the widget frame. + // Expand the window BEFORE flipping the mode. The order matters: + // setting nav.mode = .postUpdate triggers a SwiftUI re-render + // immediately, and if the window is still pill-sized at that + // point, PostUpdateView (or even the Events page during a + // transition) renders into the 320×56 frame and looks crushed. + // Resizing first guarantees the full-panel content lands in a + // full-panel-sized window. if nav.compactMode, !nav.compactExpanded { nav.compactExpanded = true + // applyCompactLayout already ran via the Combine sink; + // call again here so the synchronous setFrame is committed + // before the mode flip below schedules SwiftUI work. + applyCompactLayout() } + nav.postUpdateVersion = result.version.isEmpty ? "?" : result.version + nav.postUpdateNotes = nil + nav.mode = .postUpdate // Auto-open the panel so the user immediately sees the // "what shipped" view rather than discovering it via hotkey. DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in @@ -903,7 +918,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, applyCompactLayout() } - // Applies the user-configured pill opacity to the window. Only takes + // Applies the user-configured widget opacity to the window. Only takes // effect in pill mode; expanded panel + full-mode are always fully // opaque so the user can actually read content. private func applyCompactAlpha() { @@ -1803,8 +1818,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // From the pill (compact-not-expanded), M expands to full panel. // Mirrors the M-to-collapse shortcut shown in the full panel's - // footer. The pill must be key for this to land, so a single - // click anywhere on the pill arms it. + // footer. Gated on compactMode so the shortcut quietly no-ops + // when the user has turned off the widget. if nav.compactMode, !nav.compactExpanded, event.keyCode == KeyCode.mKey, mods.intersection([.command, .control, .option, .shift]).isEmpty { diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index b132cf1..5391e19 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -285,28 +285,29 @@ final class PanelNav: ObservableObject { // 2 Mute when focused toggle // 3 Pin panel toggle // 4 Launch at login toggle - // 5 Widget corner cycle - // 6 Mascot cycle - // 7 Pill opacity cycle (40/60/80/100%; applied window-level) - // 8 Sound enabled toggle (gates rows 9 + 10) - // 9 Agent done sound cycle - // 10 Permission sound cycle - // 11 Voice notifications toggle (gates rows 12 + 13) - // 12 Voice cycle (or "Download model" action) - // 13 Speed cycle - // 14 Quota tracking toggle (master; gates rows 15-17) - // 15 Quota alerts toggle - // 16 Alert threshold cycle - // 17 Poll frequency cycle - // 18 Context alert at cycle (per-session token thresholds) - // 19 Show remaining toggle (invert gauge readout: 70% left vs 30% used) - // 20 Edit phrases… action - // 21 Check permissions… action - // 22 Open config file… action - // 23 View release notes… action - // 24 Check for updates… action - // 25 Uninstall stack-nudge action - // 26 Quit panel action + // 5 Widget toggle (gates rows 6-8; off = classic show/hide-panel mode) + // 6 Widget corner cycle + // 7 Mascot cycle + // 8 Widget opacity cycle (40/60/80/100%; applied window-level) + // 9 Sound enabled toggle (gates rows 10 + 11) + // 10 Agent done sound cycle + // 11 Permission sound cycle + // 12 Voice notifications toggle (gates rows 13 + 14) + // 13 Voice cycle (or "Download model" action) + // 14 Speed cycle + // 15 Quota tracking toggle (master; gates rows 16-18) + // 16 Quota alerts toggle + // 17 Alert threshold cycle + // 18 Poll frequency cycle + // 19 Context alert at cycle (per-session token thresholds) + // 20 Show remaining toggle (invert gauge readout: 70% left vs 30% used) + // 21 Edit phrases… action + // 22 Check permissions… action + // 23 Open config file… action + // 24 View release notes… action + // 25 Check for updates… action + // 26 Uninstall stack-nudge action + // 27 Quit panel action // MARK: - Disk I/O @@ -340,7 +341,7 @@ final class PanelNav: ObservableObject { quotaPollMinutes = Self.quotaPollMinuteOptions.min(by: { abs($0 - rawPoll) < abs($1 - rawPoll) }) ?? 5 let rawCtx = Int(config["STACKNUDGE_CONTEXT_ALERT_THRESHOLD"] ?? "") ?? 0 contextAlertThresholdK = Self.contextAlertThresholdOptions.min(by: { abs($0 - rawCtx) < abs($1 - rawCtx) }) ?? 0 - compactMode = true // always-on; toggle removed from Settings + compactMode = ConfigFile.bool(config, "STACKNUDGE_COMPACT_MODE", default: true) compactCorner = CompactCorner(rawValue: config["STACKNUDGE_COMPACT_CORNER"] ?? "") ?? .topRight mascot = MascotKind(rawValue: config["STACKNUDGE_MASCOT"] ?? "") ?? .robot @@ -538,13 +539,13 @@ final class PanelNav: ObservableObject { } else { startVoiceModelDownload() } - case 20: actions?.editPhrases() - case 21: actions?.checkPermissions() - case 22: actions?.openConfig() - case 23: actions?.openReleaseNotes() - case 24: actions?.checkForUpdates() - case 25: actions?.beginUninstall() - case 26: actions?.quit() + case 21: actions?.editPhrases() + case 22: actions?.checkPermissions() + case 23: actions?.openConfig() + case 24: actions?.openReleaseNotes() + case 25: actions?.checkForUpdates() + case 26: actions?.beginUninstall() + case 27: actions?.quit() default: applyCycle(forward: true) } } @@ -600,36 +601,51 @@ final class PanelNav: ObservableObject { "stack-nudge: setLaunchAtLogin(\(target)) failed: \(error)\n".utf8)) } case 5: + // Widget on/off. The user is in the full panel (that's where + // the Settings row lives). On toggle-on we set compactExpanded + // so the panel stays open at full size; the pill only appears + // when the user dismisses (Esc / focus-out). On toggle-off we + // clear compactExpanded so the next applyCompactLayout commits + // the saved full-panel frame cleanly. + compactMode.toggle() + ConfigFile.write(key: "STACKNUDGE_COMPACT_MODE", + value: compactMode ? "true" : "false") + if compactMode { + compactExpanded = true + } else { + compactExpanded = false + } + case 6: let list = CompactCorner.allCases let idx = list.firstIndex(of: compactCorner) ?? 0 let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count compactCorner = list[next] ConfigFile.write(key: "STACKNUDGE_COMPACT_CORNER", value: compactCorner.rawValue) - case 6: + case 7: let list = MascotKind.allCases let idx = list.firstIndex(of: mascot) ?? 0 let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count mascot = list[next] ConfigFile.write(key: "STACKNUDGE_MASCOT", value: mascot.rawValue) - case 7: + case 8: let list = Self.compactAlphaOptions let idx = list.firstIndex(of: compactAlpha) ?? (list.count - 1) let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count compactAlpha = list[next] ConfigFile.write(key: "STACKNUDGE_COMPACT_ALPHA", value: String(format: "%.2f", compactAlpha)) - case 8: + case 9: soundEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_SOUND", value: soundEnabled ? "true" : "false") - case 9: - soundStop = step(soundStop, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_STOP", preview: true) case 10: - 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 11: + soundPermission = step(soundPermission, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_PERMISSION", preview: true) + case 12: voiceEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_VOICE", value: voiceEnabled ? "true" : "false") - case 12: + case 13: // 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. @@ -641,17 +657,17 @@ 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 13: + case 14: 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 14: - toggleQuotaTracking() case 15: + toggleQuotaTracking() + case 16: quotaAlertsEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_QUOTA_ALERTS", value: quotaAlertsEnabled ? "true" : "false") - case 16: + case 17: // Cycle through the static thresholds list. Index wraps in both // directions so the user can dial in either way. let list = Self.quotaThresholds @@ -660,21 +676,21 @@ final class PanelNav: ObservableObject { quotaAlertThreshold = list[next] ConfigFile.write(key: "STACKNUDGE_QUOTA_THRESHOLD", value: String(quotaAlertThreshold)) - case 17: + case 18: let list = Self.quotaPollMinuteOptions let idx = list.firstIndex(of: quotaPollMinutes) ?? 2 let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count quotaPollMinutes = list[next] ConfigFile.write(key: "STACKNUDGE_USAGE_POLL_MIN", value: String(quotaPollMinutes)) - case 18: + case 19: let list = Self.contextAlertThresholdOptions let idx = list.firstIndex(of: contextAlertThresholdK) ?? 0 let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count contextAlertThresholdK = list[next] ConfigFile.write(key: "STACKNUDGE_CONTEXT_ALERT_THRESHOLD", value: String(contextAlertThresholdK)) - case 19: + case 20: quotaShowRemaining.toggle() ConfigFile.write(key: "STACKNUDGE_QUOTA_SHOW_REMAINING", value: quotaShowRemaining ? "true" : "false") diff --git a/panel/SessionUsage.swift b/panel/SessionUsage.swift index 27a21a7..f5785c7 100644 --- a/panel/SessionUsage.swift +++ b/panel/SessionUsage.swift @@ -250,7 +250,7 @@ struct UsageView: View { FooterHint(label: "Sync now", keys: ["R"]) } FooterHint(label: nav.quotaTrackingEnabled ? "Pause" : "Resume", keys: ["P"]) - FooterHint(label: "Compact", keys: ["M"]) + if nav.compactMode { FooterHint(label: "Compact", keys: ["M"]) } FooterHint(label: "Hide", keys: ["Esc"]) } } diff --git a/panel/Sessions.swift b/panel/Sessions.swift index f860056..1473470 100644 --- a/panel/Sessions.swift +++ b/panel/Sessions.swift @@ -18,8 +18,10 @@ struct SessionsView: View { footer } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .onAppear { store.startPolling() } - .onDisappear { store.stopPolling() } + // Polling is started at app launch (PanelController) so the pill + // reads fresh session state without requiring this tab to be open. + // Kept here as a no-op safety in case the timer was ever stopped. + .onAppear { store.startPolling() } } private var emptyState: some View { @@ -81,7 +83,7 @@ struct SessionsView: View { FooterHint(label: "Focus", keys: ["⏎"]) FooterHint(label: "Rename", keys: ["N"]) FooterHint(label: "Kill", keys: ["⌫"]) - FooterHint(label: "Compact", keys: ["M"]) + if nav.compactMode { FooterHint(label: "Compact", keys: ["M"]) } FooterHint(label: "Back", keys: ["Esc"]) } } diff --git a/panel/Settings.swift b/panel/Settings.swift index c53ec7a..e97ac6a 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -53,44 +53,45 @@ struct SettingsView: View { } section("Widget") { - row(5 + off, label: "Widget corner", kind: .cycle, value: nav.compactCorner.label) - row(6 + off, label: "Mascot", kind: .cycle, value: nav.mascot.label) - row(7 + off, label: "Pill opacity", kind: .cycle, value: "\(Int(nav.compactAlpha * 100))%") + row(5 + off, label: "Widget", kind: .toggle, value: nav.compactMode ? "On" : "Off") + row(6 + off, label: "Widget corner", kind: .cycle, value: nav.compactCorner.label, enabled: nav.compactMode) + row(7 + off, label: "Mascot", kind: .cycle, value: nav.mascot.label, enabled: nav.compactMode) + row(8 + off, label: "Widget opacity", kind: .cycle, value: "\(Int(nav.compactAlpha * 100))%", enabled: nav.compactMode) } section("Sounds") { - row(8 + off, label: "Sound enabled", kind: .toggle, value: nav.soundEnabled ? "On" : "Off") - row(9 + off, label: "Agent done", kind: .cycle, value: nav.soundStop, enabled: nav.soundEnabled) - row(10 + off, label: "Permission", kind: .cycle, value: nav.soundPermission, enabled: nav.soundEnabled) + row(9 + off, label: "Sound enabled", kind: .toggle, value: nav.soundEnabled ? "On" : "Off") + row(10 + off, label: "Agent done", kind: .cycle, value: nav.soundStop, enabled: nav.soundEnabled) + row(11 + off, label: "Permission", kind: .cycle, value: nav.soundPermission, enabled: nav.soundEnabled) } section("Voice") { - row(11 + off, label: "Voice notifications", kind: .toggle, value: nav.voiceEnabled ? "On" : "Off") + row(12 + off, label: "Voice notifications", kind: .toggle, value: nav.voiceEnabled ? "On" : "Off") if nav.voiceModelCached { - row(12 + off, label: "Voice", kind: .cycle, value: voiceLabel, enabled: nav.voiceEnabled) - row(13 + off, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed), enabled: nav.voiceEnabled) + row(13 + off, label: "Voice", kind: .cycle, value: voiceLabel, enabled: nav.voiceEnabled) + row(14 + off, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed), enabled: nav.voiceEnabled) } else { - voiceModelDownloadRow(index: 12 + off) + voiceModelDownloadRow(index: 13 + off) } } section("Usage") { - row(14 + off, label: "Quota tracking", kind: .toggle, value: nav.quotaTrackingEnabled ? "On" : "Off") - row(15 + off, label: "Quota alerts", kind: .toggle, value: nav.quotaAlertsEnabled ? "On" : "Off", enabled: nav.quotaTrackingEnabled) - row(16 + off, label: "Alert threshold", kind: .cycle, value: "\(nav.quotaAlertThreshold)%", enabled: nav.quotaTrackingEnabled && nav.quotaAlertsEnabled) - row(17 + off, label: "Poll frequency", kind: .cycle, value: "\(nav.quotaPollMinutes) min", enabled: nav.quotaTrackingEnabled) - row(18 + off, label: "Context alert at", kind: .cycle, value: contextAlertLabel) - row(19 + off, label: "Show remaining", kind: .toggle, value: nav.quotaShowRemaining ? "On" : "Off", enabled: nav.quotaTrackingEnabled) + row(15 + off, label: "Quota tracking", kind: .toggle, value: nav.quotaTrackingEnabled ? "On" : "Off") + row(16 + off, label: "Quota alerts", kind: .toggle, value: nav.quotaAlertsEnabled ? "On" : "Off", enabled: nav.quotaTrackingEnabled) + row(17 + off, label: "Alert threshold", kind: .cycle, value: "\(nav.quotaAlertThreshold)%", enabled: nav.quotaTrackingEnabled && nav.quotaAlertsEnabled) + row(18 + off, label: "Poll frequency", kind: .cycle, value: "\(nav.quotaPollMinutes) min", enabled: nav.quotaTrackingEnabled) + row(19 + off, label: "Context alert at", kind: .cycle, value: contextAlertLabel) + row(20 + off, label: "Show remaining", kind: .toggle, value: nav.quotaShowRemaining ? "On" : "Off", enabled: nav.quotaTrackingEnabled) } section("Actions") { - row(20 + off, label: "Edit phrases…", kind: .action, value: "") - row(21 + off, label: "Check permissions…", kind: .action, value: "") - row(22 + off, label: "Open config file…", kind: .action, value: "") - row(23 + off, label: "View release notes…", kind: .action, value: "") - row(24 + off, label: "Check for updates…", kind: .action, value: checkForUpdatesStatus) - row(25 + off, label: "Uninstall StackNudge…", kind: .action, value: "") - row(26 + off, label: "Quit panel", kind: .action, value: "") + row(21 + off, label: "Edit phrases…", kind: .action, value: "") + row(22 + off, label: "Check permissions…", kind: .action, value: "") + row(23 + off, label: "Open config file…", kind: .action, value: "") + row(24 + off, label: "View release notes…", kind: .action, value: "") + row(25 + off, label: "Check for updates…", kind: .action, value: checkForUpdatesStatus) + row(26 + off, label: "Uninstall StackNudge…", kind: .action, value: "") + row(27 + off, label: "Quit panel", kind: .action, value: "") } aboutFooter diff --git a/panel/Updater.swift b/panel/Updater.swift index 8014310..7cf9f11 100644 --- a/panel/Updater.swift +++ b/panel/Updater.swift @@ -151,17 +151,36 @@ final class Updater { // 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. + // 8. Restart. Try launchctl first (handles cases where a launchd + // agent is loaded and will respawn us), then explicitly launch + // the new bundle via NSWorkspace — this covers users who never + // went through Bootstrap install, never have the panel plist, + // or whose currently-running process is from a different path + // than ~/Applications/ (e.g. a dev build still in the picture). + // Without the explicit launch, kickstart silently fails and + // the auto-quit kills the panel with nothing to restart it. setPhase(.done) - appendLog("Restarting via launchd…") + appendLog("Restarting…") 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. + relaunchInstalledBundle() scheduleAutoQuit() } + private func relaunchInstalledBundle() { + let url = URL(fileURLWithPath: Self.installedAppPath) + let cfg = NSWorkspace.OpenConfiguration() + cfg.activates = true + // newInstance=true bypasses the running-instance check so the new + // bundle starts even when an old process at a different path is + // still alive (it'll be killed by scheduleAutoQuit shortly after). + cfg.createsNewApplicationInstance = true + NSWorkspace.shared.openApplication(at: url, configuration: cfg) { [weak self] _, error in + if let error { + self?.appendLog("relaunch via NSWorkspace failed: \(error.localizedDescription)") + } + } + } + // MARK: - Release manifest private struct ReleaseInfo {