Skip to content

Commit d64b1cc

Browse files
hiskudinclaude
andauthored
fix(panel): honor explicit Quit; align side text with gauge center (#106)
* fix(panel): honor explicit Quit; align side text with gauge center Two unrelated fixes bundled together: 1. KeepAlive: true caused launchd to respawn the panel after a user clicks Quit. Switch new installs (install.sh + Bootstrap.writePlist) to the dict form `KeepAlive: { SuccessfulExit: false }` so the process restarts on crashes but respects clean exits. Existing installs get a one-shot migration in cleanupPostUpdateBackup that rewrites the plist on launch and reloads launchd, so the fix takes effect without a reinstall. 2. CompactView's sideText (resting countdown / hover legend) sat above the gauge digit's y-center because its intrinsic height differed from the gauge cluster's. Pin sideText to the gauge cluster's full height (size + 2 to match the halo Circle) so the ZStack's center alignment lines up with QuotaGauge's centerReadout in both compact-content modes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(panel): honor 429 Retry-After on the Claude usage probe Hammering /api/oauth/usage with the same source IP kept stoking the rate limit — once a 429 streak started it never cleared because every 60s poll kept the window alive. Track retryAfterUntil; while it's in the future, short-circuit fetch() before hitting the network. Parse the Retry-After header (delta-seconds or HTTP-date), fall back to 15 minutes when absent, clamp at 1 hour so a bogus value can't strand the probe. Cleared on the next 200. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(panel): keep quit quit — don't relaunch via notify.sh User Quit now drops a marker at ~/.stack-nudge/user-quit; notify.sh's ensure_app_running gate honors it and stops opening the bundle on hook events. The marker is cleared on the next manual launch, so reopening restores normal auto-relaunch behavior. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(widget): align usage side text with gauge + match hover legend weight Drop the explicit Spacer in the usage pill so sideText fills the slack (maxWidth: .infinity, leading) — gauge center and text center now share the same y, and the expand button sits closer to the cluster instead of being pushed to the far edge. Widen the pill from 150→170 to give the two-line hover legend room without compressing the layout, and bump the resting countdown to .medium/.secondary so it matches the hover legend's visual weight (sizes were already equal at 9pt — the perceived gap was weight + color tier). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(widget): simplify mini + full pill — symmetric margins, hover stability, cycling-only headline Mini pill (usage mode): - Reserve hoverLegend's measured width via PreferenceKey so the countdown sits in a fixed-width slot — hovering no longer bumps the expand button. - Vertically center the visible text in sideText (countdown / hover legend / "—" placeholder when no quota). - Gate hover on nav.quota != nil so we don't flash placeholder legend text on hover. - Shrink pill 170→145 and bump horizontal padding 4→10 so both edge gaps resolve to the same value. Full pill (non-usage mode): - Drop the middle Spacer + trailing minLength-4 Spacer in favor of a single trailing Spacer; sessionBadge + expandButton now sit right next to the headline. - Shrink pill 320→290 so the right margin matches the left 12pt padding. - Strip the busy / recent-event / most-recent-active branches in the headline — always show cycling active session names (falls back to "watching" when no sessions are active). Drops the · token-count and · position indicators from the cycling view. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(panel): address PR #106 review feedback - writePlist: parameterize KeepAlive so the voice daemon keeps its "always" restart policy. Wizard-installed daemons were inheriting the panel's SuccessfulExit:false form, diverging from install.sh. - QuotaProbe: distinguish "rate-limited" from "endpoint may have changed" in the Usage tab so the message is accurate during a 429 backoff. isRateLimited reads off retryAfterUntil; lastProbeFailed now only flags real failures (non-200, non-429). - parseRetryAfter: refactor to take String? so it can be unit-tested without an HTTPURLResponse; add tests covering delta-seconds, HTTP-date, negative values, >1h clamp, empty, and garbage input. - gaugeClusterBody: extract `haloSize = size + 2` so the halo blur frame and gauge frame share one source of truth; drop redundant explicit HStack(alignment: .center). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ead6c90 commit d64b1cc

8 files changed

Lines changed: 250 additions & 105 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import XCTest
2+
3+
@testable import StackNudgePanelCore
4+
5+
final class QuotaProbeRetryAfterTests: XCTestCase {
6+
7+
func testNilHeader() {
8+
XCTAssertNil(QuotaProbe.parseRetryAfter(nil))
9+
}
10+
11+
func testEmptyHeader() {
12+
XCTAssertNil(QuotaProbe.parseRetryAfter(""))
13+
XCTAssertNil(QuotaProbe.parseRetryAfter(" "))
14+
}
15+
16+
func testDeltaSeconds() {
17+
XCTAssertEqual(QuotaProbe.parseRetryAfter("30"), 30)
18+
XCTAssertEqual(QuotaProbe.parseRetryAfter(" 120 "), 120)
19+
XCTAssertEqual(QuotaProbe.parseRetryAfter("0"), 0)
20+
}
21+
22+
func testNegativeDeltaIsRejected() {
23+
// Per RFC 7231 delta-seconds must be non-negative — fall through to
24+
// date parsing (which fails) and return nil.
25+
XCTAssertNil(QuotaProbe.parseRetryAfter("-5"))
26+
}
27+
28+
func testClampedToMax() {
29+
let huge = QuotaProbe.maxRetryAfter * 4
30+
XCTAssertEqual(QuotaProbe.parseRetryAfter("\(Int(huge))"),
31+
QuotaProbe.maxRetryAfter)
32+
}
33+
34+
func testHTTPDateInFuture() {
35+
let future = Date().addingTimeInterval(120)
36+
let f = DateFormatter()
37+
f.locale = Locale(identifier: "en_US_POSIX")
38+
f.timeZone = TimeZone(identifier: "GMT")
39+
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
40+
let header = f.string(from: future)
41+
42+
let parsed = QuotaProbe.parseRetryAfter(header)
43+
XCTAssertNotNil(parsed)
44+
// Allow ±2s clock drift between Date() in the test and Date() inside
45+
// parseRetryAfter.
46+
XCTAssertEqual(parsed ?? 0, 120, accuracy: 2)
47+
}
48+
49+
func testHTTPDateInPastReturnsZero() {
50+
let past = Date().addingTimeInterval(-3600)
51+
let f = DateFormatter()
52+
f.locale = Locale(identifier: "en_US_POSIX")
53+
f.timeZone = TimeZone(identifier: "GMT")
54+
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
55+
XCTAssertEqual(QuotaProbe.parseRetryAfter(f.string(from: past)), 0)
56+
}
57+
58+
func testGarbageReturnsNil() {
59+
XCTAssertNil(QuotaProbe.parseRetryAfter("not a number"))
60+
XCTAssertNil(QuotaProbe.parseRetryAfter("Mon, totally not a date"))
61+
XCTAssertNil(QuotaProbe.parseRetryAfter("3.14e10x"))
62+
}
63+
}

install.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,10 +203,10 @@ if [[ "$(uname -s)" == "Darwin" ]]; then
203203
"${VENV}/bin/stackvox" "serve"
204204
echo " Voice daemon registered as launchd agent (starts at login)"
205205

206-
# Single persistent app — always running, restarts on crash.
206+
# Single persistent app — restarts on crash; respects explicit user Quit.
207207
register_launchd_agent \
208208
"com.stackonehq.stack-nudge" \
209-
"always" \
209+
"on_crash" \
210210
"${INSTALL_DIR}/app.log" \
211211
"$HOME/Applications/StackNudge.app/Contents/MacOS/stack-nudge"
212212
echo " App registered as launchd agent (starts at login)"

notify.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,9 @@ nudge_debug() {
312312
# old bundle is still running with the new hook script — keeps working.
313313
ensure_app_running() {
314314
[[ -S "$PANEL_SOCK" ]] && return
315+
# User explicitly Quit. Stay quiet until they reopen the app. Marker
316+
# is cleared on the next manual launch by Bootstrap.clearUserQuitMarker.
317+
[[ -f "$HOME/.stack-nudge/user-quit" ]] && return
315318
local app_path=""
316319
if [[ -d "$HOME/Applications/StackNudge.app" ]]; then
317320
app_path="$HOME/Applications/StackNudge.app"

panel/Bootstrap.swift

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ enum Bootstrap {
7878
static let venvSymlinkPath = "\(NSHomeDirectory())/.stack-nudge/venv"
7979
static let configPath = "\(NSHomeDirectory())/.stack-nudge/config"
8080
static let phrasesDir = "\(NSHomeDirectory())/.stack-nudge/phrases"
81+
static let userQuitMarker = "\(NSHomeDirectory())/.stack-nudge/user-quit"
8182

8283
static let launchAgentsDir = "\(NSHomeDirectory())/Library/LaunchAgents"
8384
static let appLabel = "com.stackonehq.stack-nudge"
@@ -140,6 +141,18 @@ enum Bootstrap {
140141
NSWorkspace.shared.recycle([backup]) { _, _ in }
141142
}
142143

144+
// User-initiated Quit: drop a marker file so notify.sh's ensure_app_running
145+
// gate doesn't relaunch us on the next hook event. Cleared by
146+
// clearUserQuitMarker() on the next manual app launch.
147+
static func userQuit() {
148+
_ = try? "".write(toFile: userQuitMarker, atomically: true, encoding: .utf8)
149+
NSApp.terminate(nil)
150+
}
151+
152+
static func clearUserQuitMarker() {
153+
try? FileManager.default.removeItem(atPath: userQuitMarker)
154+
}
155+
143156
static func migrateBundleNameIfNeeded() {
144157
let fm = FileManager.default
145158
let runningFromNewPath = Bundle.main.bundleURL.lastPathComponent == "StackNudge.app"
@@ -157,6 +170,27 @@ enum Bootstrap {
157170
// write them when the user finishes the wizard.
158171
retargetLaunchAgentIfNeeded(label: appLabel)
159172
retargetLaunchAgentIfNeeded(label: daemonLabel)
173+
migrateKeepAliveIfNeeded(label: appLabel)
174+
}
175+
176+
// Older installs wrote KeepAlive: true, so launchd respawned the panel
177+
// even after an explicit user Quit. Rewrite to the dict form that
178+
// restarts on crash only.
179+
private static func migrateKeepAliveIfNeeded(label: String) {
180+
let fm = FileManager.default
181+
let plistPath = "\(launchAgentsDir)/\(label).plist"
182+
guard fm.fileExists(atPath: plistPath),
183+
let data = try? Data(contentsOf: URL(fileURLWithPath: plistPath)),
184+
var plist = (try? PropertyListSerialization.propertyList(
185+
from: data, options: [], format: nil)) as? [String: Any]
186+
else { return }
187+
guard (plist["KeepAlive"] as? Bool) == true else { return }
188+
plist["KeepAlive"] = ["SuccessfulExit": false]
189+
guard let updated = try? PropertyListSerialization.data(
190+
fromPropertyList: plist, format: .xml, options: 0) else { return }
191+
try? updated.write(to: URL(fileURLWithPath: plistPath), options: [.atomic])
192+
_ = try? runLaunchctl(["unload", plistPath])
193+
_ = try? runLaunchctl(["load", plistPath])
160194
}
161195

162196
// Read the on-disk launchd plist for `label`; if its first program-
@@ -639,9 +673,13 @@ enum Bootstrap {
639673
let python = venvURL.appendingPathComponent("bin/python3").path
640674
let stackvox = venvURL.appendingPathComponent("bin/stackvox").path
641675
let logPath = "\(installDir)/daemon.log"
676+
// Daemon mirrors install.sh's "always" mode — if stackvox serve ever
677+
// exits 0 launchd should still bring it back. Only the panel uses the
678+
// SuccessfulExit:false form so a user Quit actually quits.
642679
try writePlist(label: daemonLabel,
643680
programArgs: [python, stackvox, "serve"],
644681
logPath: logPath,
682+
keepAlive: true,
645683
env: stackvoxEnv(venvURL: venvURL))
646684
}
647685

@@ -660,15 +698,20 @@ enum Bootstrap {
660698

661699
// Common plist serialiser: emits the same XML shape install.sh's
662700
// register_launchd_agent function produces, via PropertyListSerialization.
701+
//
702+
// `keepAlive` mirrors install.sh's "always" vs "on_crash" modes:
703+
// true → restart unconditionally
704+
// ["SuccessfulExit": false] → restart on crash only (Quit means quit)
663705
private static func writePlist(label: String,
664706
programArgs: [String],
665707
logPath: String,
708+
keepAlive: Any = ["SuccessfulExit": false],
666709
env: [String: String] = [:]) throws {
667710
var plist: [String: Any] = [
668711
"Label": label,
669712
"ProgramArguments": programArgs,
670713
"RunAtLoad": true,
671-
"KeepAlive": true,
714+
"KeepAlive": keepAlive,
672715
"StandardOutPath": logPath,
673716
"StandardErrorPath": logPath,
674717
]

0 commit comments

Comments
 (0)