diff --git a/panel/Bootstrap.swift b/panel/Bootstrap.swift index 5dc97f4..0077aea 100644 --- a/panel/Bootstrap.swift +++ b/panel/Bootstrap.swift @@ -123,6 +123,23 @@ enum Bootstrap { // 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. + // The Updater leaves `~/Applications/StackNudge.app.old` behind after a + // successful swap as a rollback safety net. By the time we're running, + // launchd has already brought up the new bundle and the user is + // interacting with it — the .old is now just dead weight that Spotlight + // indexes, producing a confusing duplicate hit. Trash it so future + // searches only find the live app. + // + // Idempotent: no-op when the .old doesn't exist (steady state for users + // who installed fresh or who already had it cleaned up). + static func cleanupPostUpdateBackup() { + let backup = URL(fileURLWithPath: Updater.installedAppPath + ".old") + guard FileManager.default.fileExists(atPath: backup.path) else { return } + // Recycle (not rm -rf) so the user can restore from Trash if they + // somehow notice a regression we don't. + NSWorkspace.shared.recycle([backup]) { _, _ in } + } + static func migrateBundleNameIfNeeded() { let fm = FileManager.default let runningFromNewPath = Bundle.main.bundleURL.lastPathComponent == "StackNudge.app" diff --git a/panel/CompactView.swift b/panel/CompactView.swift index 13c1c15..b249db6 100644 --- a/panel/CompactView.swift +++ b/panel/CompactView.swift @@ -107,7 +107,12 @@ struct CompactView: View { @ViewBuilder private var headline: some View { HStack(spacing: 6) { - BotMascot(state: botState, kind: nav.mascot, paused: nav.compactDragging, hovered: mascotHovered) + BotMascot(state: botState, + kind: nav.mascot, + dragging: nav.compactDragging, + hovered: mascotHovered, + eventReaction: nav.lastEventReaction, + stressed: quotaStressed) .frame(width: 26, height: 24) headlineText } @@ -167,6 +172,16 @@ struct CompactView: View { } } + // Passive quota-stress signal for the mascot. True when either the 5h + // session window or the 7d weekly utilization is ≥75%. The mascot + // shows a tired/worried expression so the user sees the budget + // pressure without opening the Usage tab. + private var quotaStressed: Bool { + let five = nav.quota?.fiveHour?.utilization ?? 0 + let seven = nav.quota?.sevenDay?.utilization ?? 0 + return five >= 75 || seven >= 75 + } + private var botState: BotState { if let _ = recentEvent { // Most recent event drives expression briefly @@ -487,15 +502,30 @@ private struct BotMascot: View { let state: BotState let kind: MascotKind - let paused: Bool + // Was `paused` — same value (nav.compactDragging) but renamed because + // each mascot now uses it for two things: gating the heavy TimelineView + // idle animations (the original perf concern) and triggering a + // lightweight drag-personality animation (.scaleEffect/.offset only). + let dragging: Bool let hovered: Bool + // One-shot pulse driven by EventStore.onAppend → nav.reactToEvent. + // Cleared back to nil after 0.8s. Each mascot reads it in onChange to + // kick off a kind-specific animation. + let eventReaction: NudgeKind? + // Passive quota-stress signal. Each mascot renders a tired/worried + // accent (sweat-drop, droopy mouth, bloodshot tint, translucent body). + let stressed: Bool var body: some View { switch kind { - case .robot: RobotMascot(state: state, paused: paused, hovered: hovered) - case .cat: CatMascot(state: state, paused: paused, hovered: hovered) - case .eye: EyeMascot(state: state, paused: paused, hovered: hovered) - case .ghost: GhostMascot(state: state, paused: paused, hovered: hovered) + case .robot: RobotMascot(state: state, dragging: dragging, hovered: hovered, + eventReaction: eventReaction, stressed: stressed) + case .cat: CatMascot(state: state, dragging: dragging, hovered: hovered, + eventReaction: eventReaction, stressed: stressed) + case .eye: EyeMascot(state: state, dragging: dragging, hovered: hovered, + eventReaction: eventReaction, stressed: stressed) + case .ghost: GhostMascot(state: state, dragging: dragging, hovered: hovered, + eventReaction: eventReaction, stressed: stressed) } } } @@ -503,36 +533,132 @@ private struct BotMascot: View { private struct RobotMascot: View { let state: BotState - let paused: Bool + let dragging: Bool let hovered: Bool + let eventReaction: NudgeKind? + let stressed: Bool + + // One-shot pulse state driven by eventReaction's onChange handler. Goes + // 0 → 1 on event arrival, springs back to 0 over ~0.8s. Per-kind colors + // and visuals are derived from the latched `pulseKind` so the animation + // can run after the published value has cleared. + @State private var pulse: Double = 0 + @State private var pulseKind: NudgeKind? private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) private static let outline = Color.secondary.opacity(0.7) var body: some View { - if paused { - // Static: no blink, no antenna pulse, no TimelineView ticks. - ZStack { - head - staticAntenna - staticEyes - mouth - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - TimelineView(.animation(minimumInterval: 0.05)) { tl in - let t = tl.date.timeIntervalSinceReferenceDate + Group { + if dragging { + // Drag: skip TimelineView to keep the AppKit drag handler + // snappy. Tiny scale + pixel-glitch jitter gives the robot + // a "GLITCH!" personality without per-frame cost. ZStack { head - antenna(at: t) - eyes(at: t) + staticAntenna + staticEyes mouth } .frame(maxWidth: .infinity, maxHeight: .infinity) + .scaleEffect(1.05) + .offset(x: dragGlitchOffset) + } else { + TimelineView(.animation(minimumInterval: 0.05)) { tl in + let t = tl.date.timeIntervalSinceReferenceDate + ZStack { + head + antenna(at: t) + eyes(at: t) + mouth + if let kind = pulseKind, pulse > 0 { + reactionOverlay(kind: kind) + } + if stressed { sweatDrop } + if idleTilting(at: t) { + EmptyView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .rotationEffect(.degrees(idleTiltAngle(at: t))) + } + } + } + .animation(.spring(response: 0.18, dampingFraction: 0.6), value: dragging) + .onChange(of: eventReaction) { kind in + guard let kind else { return } + pulseKind = kind + // Pulse up fast, decay slower — feels like a flash that fades. + withAnimation(.spring(response: 0.18, dampingFraction: 0.55)) { + pulse = 1 + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + withAnimation(.easeOut(duration: 0.45)) { pulse = 0 } } } } + // Deterministic seed so multi-monitor pills don't sync. Offsets the + // sin() phase by a per-mascot-kind constant. + private static let idleSeed: Double = 0 + // Tilt the head ~2° left/right every ~12s. The truncatingRemainder cycle + // is long enough that the motion reads as "robot occasionally looks + // around" rather than "robot is rocking." + private func idleTiltAngle(at t: TimeInterval) -> Double { + let phase = (t + Self.idleSeed).truncatingRemainder(dividingBy: 12.0) + // Active arc only during the final 1.5s of each 12s window. + guard phase > 10.5 else { return 0 } + let local = (phase - 10.5) / 1.5 // 0…1 + return sin(local * .pi * 2) * 2 + } + private func idleTilting(at t: TimeInterval) -> Bool { idleTiltAngle(at: t) != 0 } + + private var dragGlitchOffset: CGFloat { + // Deterministic micro-jitter; stable across frames because dragging + // is a Bool. The user sees an instant offset on grab + release. + dragging ? 0.5 : 0 + } + + @ViewBuilder + private func reactionOverlay(kind: NudgeKind) -> some View { + switch kind { + case .stop: + // Green flash over the antenna + mouth area. + Circle() + .fill(Color.green.opacity(0.55 * pulse)) + .frame(width: 16, height: 16) + .blur(radius: 4) + .offset(y: -6) + case .permission: + // Yellow flash over the head. + RoundedRectangle(cornerRadius: 6) + .fill(Color.yellow.opacity(0.45 * pulse)) + .blur(radius: 4) + .frame(width: 22, height: 18) + .offset(y: 2) + case .other: + // Subtle cyan cheek pulse. + HStack(spacing: 9) { + Circle() + .fill(Self.cyan.opacity(0.55 * pulse)) + .frame(width: 3, height: 3) + Circle() + .fill(Self.cyan.opacity(0.55 * pulse)) + .frame(width: 3, height: 3) + } + .offset(y: 4) + } + } + + private var sweatDrop: some View { + // Tiny blue teardrop above the right side of the head — passive + // quota-stress signal. Only renders when `stressed` is true. + Circle() + .fill(Color(red: 0.45, green: 0.75, blue: 1.0).opacity(0.85)) + .frame(width: 2.8, height: 2.8) + .offset(x: 8, y: -4) + } + private var staticAntenna: some View { VStack(spacing: 0) { Circle().fill(antennaColor.opacity(0.9)) @@ -691,25 +817,102 @@ private struct RobotMascot: View { private struct CatMascot: View { let state: BotState - let paused: Bool + let dragging: Bool let hovered: Bool + let eventReaction: NudgeKind? + let stressed: Bool + + @State private var pulse: Double = 0 + @State private var pulseKind: NudgeKind? private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) private static let outline = Color.secondary.opacity(0.7) var body: some View { - if paused { - ZStack { head; staticEyes; mouth; whiskers; blep } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - TimelineView(.animation(minimumInterval: 0.05)) { tl in - let t = tl.date.timeIntervalSinceReferenceDate - ZStack { head; eyes(at: t); mouth; whiskers; blep } + Group { + if dragging { + ZStack { head; staticEyes; mouth; whiskers; blep } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .scaleEffect(x: 1.0, y: 1.08) // tail-up cling + .offset(y: -1) + } else { + TimelineView(.animation(minimumInterval: 0.05)) { tl in + let t = tl.date.timeIntervalSinceReferenceDate + ZStack { + head + eyes(at: t) + mouth + whiskers + blep + if let kind = pulseKind, pulse > 0 { + reactionOverlay(kind: kind) + } + if stressed { stressOverlay } + } .frame(maxWidth: .infinity, maxHeight: .infinity) + .offset(y: idleEarFlick(at: t)) + } + } + } + .animation(.spring(response: 0.2, dampingFraction: 0.6), value: dragging) + .onChange(of: eventReaction) { kind in + guard let kind else { return } + pulseKind = kind + withAnimation(.spring(response: 0.18, dampingFraction: 0.55)) { pulse = 1 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + withAnimation(.easeOut(duration: 0.45)) { pulse = 0 } } } } + // Tiny vertical bump every ~10s — reads as "ear flick". + private static let idleSeed: Double = 2.7 // offset from robot's phase + private func idleEarFlick(at t: TimeInterval) -> CGFloat { + let phase = (t + Self.idleSeed).truncatingRemainder(dividingBy: 10.0) + guard phase > 9.4 else { return 0 } + return CGFloat(-sin((phase - 9.4) / 0.6 * .pi)) * 0.8 + } + + @ViewBuilder + private func reactionOverlay(kind: NudgeKind) -> some View { + switch kind { + case .stop: + // Ears-perked glow above the head. + Capsule() + .fill(Color.green.opacity(0.5 * pulse)) + .frame(width: 14, height: 4) + .blur(radius: 3) + .offset(y: -8) + case .permission: + // Yellow halo for "looking at you, please respond". + Circle() + .fill(Color.yellow.opacity(0.4 * pulse)) + .blur(radius: 5) + .frame(width: 22, height: 22) + case .other: + // Cyan whisker shimmer. + Capsule() + .fill(Self.cyan.opacity(0.5 * pulse)) + .frame(width: 18, height: 1.5) + .blur(radius: 1.5) + .offset(y: 4.5) + } + } + + private var stressOverlay: some View { + // Slightly droopy frown (overrides the default cat mouth) when + // quota is high. Drawn small so it reads more as "concerned" than + // "sad". + Path { p in + p.move(to: CGPoint(x: 0, y: 1)) + p.addQuadCurve(to: CGPoint(x: 4, y: 1), + control: CGPoint(x: 2, y: -0.5)) + } + .stroke(Color.orange.opacity(0.8), style: StrokeStyle(lineWidth: 0.9, lineCap: .round)) + .frame(width: 4, height: 1.5) + .offset(y: 5) + } + // Ears perked = head + ear-hints lift slightly while hovered. private var earLift: CGFloat { hovered ? -1.5 : 0 } @@ -907,23 +1110,100 @@ private struct CatHeadShape: Shape { private struct EyeMascot: View { let state: BotState - let paused: Bool + let dragging: Bool let hovered: Bool + let eventReaction: NudgeKind? + let stressed: Bool + + @State private var pulse: Double = 0 + @State private var pulseKind: NudgeKind? + // Used by drag-personality: pupil lags slightly behind the drag + // direction. Set true while dragging, cleared on release. + @State private var lagging: Bool = false private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) private static let outline = Color.secondary.opacity(0.7) var body: some View { - if paused { - ZStack { lens; staticPupil; eyebrow } + Group { + if dragging { + ZStack { + lens + staticPupil.offset(x: -2) // pupil lags drag (offset opposite) + eyebrow + if stressed { stressOverlay } + } .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - TimelineView(.animation(minimumInterval: 0.05)) { tl in - let t = tl.date.timeIntervalSinceReferenceDate - ZStack { lens; pupil(at: t); eyebrow } + } else { + TimelineView(.animation(minimumInterval: 0.05)) { tl in + let t = tl.date.timeIntervalSinceReferenceDate + ZStack { + lens + pupil(at: t) + eyebrow + if let kind = pulseKind, pulse > 0 { + reactionOverlay(kind: kind) + } + if stressed { stressOverlay } + } .frame(maxWidth: .infinity, maxHeight: .infinity) + .scaleEffect(y: idleBlinkScale(at: t), anchor: .center) + } } } + .animation(.spring(response: 0.18, dampingFraction: 0.55), value: dragging) + .onChange(of: eventReaction) { kind in + guard let kind else { return } + pulseKind = kind + withAnimation(.spring(response: 0.18, dampingFraction: 0.55)) { pulse = 1 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + withAnimation(.easeOut(duration: 0.45)) { pulse = 0 } + } + } + } + + // Squint-and-open every ~9s. Returns vertical scale that dips toward + // 0.4 briefly so the lens looks like it's closing for a moment. + private static let idleSeed: Double = 5.1 + private func idleBlinkScale(at t: TimeInterval) -> CGFloat { + let phase = (t + Self.idleSeed).truncatingRemainder(dividingBy: 9.0) + guard phase > 8.7 else { return 1 } + let local = (phase - 8.7) / 0.3 // 0…1 over 300ms + return CGFloat(1.0 - 0.6 * sin(local * .pi)) + } + + @ViewBuilder + private func reactionOverlay(kind: NudgeKind) -> some View { + switch kind { + case .stop: + // Brief green ring around the lens — eye "registers" success. + Circle() + .stroke(Color.green.opacity(0.7 * pulse), lineWidth: 1.6) + .frame(width: 24, height: 24) + .offset(y: 1) + case .permission: + // Wider yellow halo — alarmed pupil. + Circle() + .fill(Color.yellow.opacity(0.35 * pulse)) + .blur(radius: 5) + .frame(width: 22, height: 22) + .offset(y: 1) + case .other: + // Single cyan tick at the top — small blip. + Capsule() + .fill(Self.cyan.opacity(0.6 * pulse)) + .frame(width: 6, height: 1.5) + .offset(y: -10) + } + } + + private var stressOverlay: some View { + // Subtle red tint behind the pupil + tiny droop offset. Reads as + // bloodshot/tired without redesigning the lens. + Circle() + .fill(Color.red.opacity(0.18)) + .frame(width: 14, height: 14) + .offset(y: 1) } // Hover-only eyebrow arc above the lens — gives the sentinel a @@ -1014,36 +1294,93 @@ private struct EyeMascot: View { private struct GhostMascot: View { let state: BotState - let paused: Bool + let dragging: Bool let hovered: Bool + let eventReaction: NudgeKind? + let stressed: Bool + + @State private var pulse: Double = 0 + @State private var pulseKind: NudgeKind? private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) private static let outline = Color.secondary.opacity(0.7) var body: some View { - if paused { - ZStack { - sparkles - ZStack { body_; staticEyes; mouth } - .offset(y: hovered ? -4 : 0) - .scaleEffect(hovered ? 1.08 : 1.0) - .animation(.spring(response: 0.32, dampingFraction: 0.55), value: hovered) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - TimelineView(.animation(minimumInterval: 0.05)) { tl in - let t = tl.date.timeIntervalSinceReferenceDate - let bob = sin(t * 1.3) * 1.0 + Group { + if dragging { ZStack { sparkles - ZStack { body_; eyes(at: t); mouth } - .offset(y: bob + (hovered ? -4 : 0)) + trailingSparkle // one extra trailing sparkle while dragging + ZStack { body_; staticEyes; mouth } + .offset(y: hovered ? -4 : 0) .scaleEffect(hovered ? 1.08 : 1.0) .animation(.spring(response: 0.32, dampingFraction: 0.55), value: hovered) + .opacity(0.85) // floaty translucent while dragging } .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + TimelineView(.animation(minimumInterval: 0.05)) { tl in + let t = tl.date.timeIntervalSinceReferenceDate + let bob = sin(t * 1.3) * 1.0 + ZStack { + sparkles + ZStack { + body_ + eyes(at: t) + mouth + if let kind = pulseKind, pulse > 0 { + reactionOverlay(kind: kind) + } + } + .offset(y: bob + (hovered ? -4 : 0)) + .scaleEffect(hovered ? 1.08 : 1.0) + .animation(.spring(response: 0.32, dampingFraction: 0.55), value: hovered) + .opacity(stressed ? 0.7 : 1.0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } } + .animation(.easeInOut(duration: 0.25), value: dragging) + .onChange(of: eventReaction) { kind in + guard let kind else { return } + pulseKind = kind + withAnimation(.spring(response: 0.18, dampingFraction: 0.55)) { pulse = 1 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + withAnimation(.easeOut(duration: 0.45)) { pulse = 0 } + } + } + } + + private var trailingSparkle: some View { + sparkle + .scaleEffect(0.6) + .opacity(0.7) + .offset(x: -11, y: 3) + } + + @ViewBuilder + private func reactionOverlay(kind: NudgeKind) -> some View { + switch kind { + case .stop: + // Star burst — reuse sparkle motif, scaled up. + sparkle + .scaleEffect(1.6 + 0.4 * pulse) + .opacity(pulse) + .offset(y: -10) + case .permission: + // "?" floats above the ghost. + Text("?") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(Color.yellow.opacity(pulse)) + .offset(y: -12) + case .other: + // Soft cyan ring around the body. + Capsule() + .fill(Self.cyan.opacity(0.35 * pulse)) + .frame(width: 22, height: 26) + .blur(radius: 4) + } } // Three little sparkles around the ghost on hover. They fade in via diff --git a/panel/Panel.swift b/panel/Panel.swift index 64e2bef..2c4c39e 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -553,6 +553,11 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // stale bundle + rewrite the launchd plist so launchctl points // at us, not the old path. Bootstrap.migrateBundleNameIfNeeded() + // Updater preserves the previous bundle at StackNudge.app.old as a + // rollback safety net. We're the new bundle, we've successfully + // launched, so the safety net has served its purpose — recycle it + // so Spotlight stops indexing two StackNudge.app entries. + Bootstrap.cleanupPostUpdateBackup() let size = Self.loadSavedPanelSize() let frame = NSRect(origin: .zero, size: size) @@ -644,6 +649,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, self?.lastEventArrivalAt = Date() self?.postBannerIfNeeded(event) self?.refreshTranscriptStats(for: event) + self?.nav.reactToEvent(event.kind) } nav.loadFromConfig() // populate panelPinned + other live values up-front // Scan agent configs for missing wires (post-update / post-install @@ -900,7 +906,13 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, let size = Self.loadSavedPanelSize() var frame = panel.frame frame.size = size - panel.setFrame(frame, display: true, animate: false) + // display:false defers the redraw to AppKit's next layout pass, + // by which time SwiftUI has re-evaluated body and replaced + // CompactView with the full panel content. With display:true + // here, AppKit forces an immediate paint while SwiftUI still + // has the stale CompactView in its tree — the pill renders into + // the new larger frame for one frame and the user sees a flicker. + panel.setFrame(frame, display: false, animate: false) // Restore the original layout-protecting minimum so SwiftUI's // full panel content (Settings, Sessions, etc.) has room. panel.contentMinSize = NSSize(width: 560, height: 260) diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index 765b128..9cd12d4 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -207,6 +207,12 @@ final class PanelNav: ObservableObject { // animations so the main thread can keep up with AppKit's drag // handler. Reset by the controller's mouse-event monitor. @Published var compactDragging: Bool = false + // Most recent NudgeKind for the mascot to react to. Set by + // reactToEvent(_:) when EventStore appends a new event; cleared back to + // nil after 0.8s so the reaction is a one-shot pulse rather than a + // sticky state. Read by the per-mascot views in CompactView. + @Published var lastEventReaction: NudgeKind? + private var eventReactionClearTimer: Timer? // First-launch bootstrap wizard state. Populated by PanelController // on launch when Bootstrap.isInstalled() returns false; drives // BootstrapView (mode = .bootstrap). @@ -279,7 +285,13 @@ final class PanelNav: ObservableObject { // when the offset is 1. var updateRowOffset: Int { updateAvailable != nil ? 1 : 0 } - var rowCount: Int { 25 + updateRowOffset } + // 29 rows in the body: hotkey (1) + Toggles (4) + Widget (4) + Sounds (3) + // + Voice (2 — Voice + Speed, or 1 Download row with an unused index 14) + // + Usage (6) + Events (1) + Actions (7) = indices 0…28. Must be kept + // in sync with the row(...) calls in Settings.swift and the case bodies + // in applyCycle/activate; off-by-one here makes the last rows wrap to 0 + // on the down-arrow. + var rowCount: Int { 29 + updateRowOffset } // Row layout (kept in one place so the controller, view, and indexing // logic all agree on what each row index means). When updateAvailable @@ -316,6 +328,20 @@ final class PanelNav: ObservableObject { // MARK: - Disk I/O + // Trigger a one-shot mascot reaction tied to the kind of event that + // just arrived. The pill mascot picks this up via @Published and runs + // an 800ms animation specific to .stop / .permission / .other; we + // clear it back to nil after the same window so the next event can + // re-trigger (Published only re-fires on change). + func reactToEvent(_ kind: NudgeKind) { + lastEventReaction = kind + eventReactionClearTimer?.invalidate() + eventReactionClearTimer = Timer.scheduledTimer(withTimeInterval: 0.8, + repeats: false) { [weak self] _ in + self?.lastEventReaction = nil + } + } + func loadFromConfig() { let config = ConfigFile.read() hotkeyDisplay = config["STACKNUDGE_PANEL_HOTKEY"] ?? "cmd+opt+n" diff --git a/panel/Permissions.swift b/panel/Permissions.swift index c84553a..67aeee4 100644 --- a/panel/Permissions.swift +++ b/panel/Permissions.swift @@ -259,10 +259,20 @@ struct PermissionsView: View { } +// NSWindow doesn't handle Esc out of the box; cancelOperation is the +// standard selector AppKit sends when the user hits Escape (or ⌘. ) in +// a window that has no default text-field cancel target. Override to +// close, so the permissions sheet feels like every other macOS modal. +private final class EscClosesWindow: NSWindow { + override func cancelOperation(_ sender: Any?) { + performClose(sender) + } +} + final class PermissionsWindowController: NSWindowController { convenience init() { - let window = NSWindow( + let window = EscClosesWindow( contentRect: NSRect(x: 0, y: 0, width: 480, height: 440), styleMask: [.titled, .closable], backing: .buffered, defer: false) diff --git a/panel/SessionStore.swift b/panel/SessionStore.swift index 8dbd9b9..f19554b 100644 --- a/panel/SessionStore.swift +++ b/panel/SessionStore.swift @@ -69,6 +69,14 @@ final class SessionStore: ObservableObject { } func startPolling() { + // One-shot sweep of dead-PID sidecars left in ~/.claude/sessions/. + // Claude Code writes these but doesn't garbage-collect them, so they + // accumulate over weeks of use. Conservative guards (PID actually + // dead + file at least 5 min old) keep us from racing a session + // that's just starting up or mid-write. + DispatchQueue.global(qos: .utility).async { + Self.sweepStaleClaudeSidecars() + } scan() pollTimer?.invalidate() pollTimer = Timer.scheduledTimer(withTimeInterval: Self.pollInterval, repeats: true) { [weak self] _ in @@ -301,6 +309,38 @@ final class SessionStore: ObservableObject { return nil } + // Walk ~/.claude/sessions/ and recycle any .json whose PID is no + // longer running. Claude Code writes these on session start but doesn't + // remove them on exit, so a long-lived user accumulates dozens of stale + // entries which (a) take up inodes, (b) risk surfacing stale data if a + // future PID collides with a dead session's PID. Age guard (file must + // be at least 5 minutes old) protects against deleting the sidecar of + // a session that's mid-bootstrap on a freshly-reused PID. + private static func sweepStaleClaudeSidecars() { + let dir = "\(NSHomeDirectory())/.claude/sessions" + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory(atPath: dir) else { return } + let minAge: TimeInterval = 300 + let now = Date() + for entry in entries { + guard entry.hasSuffix(".json") else { continue } + let basename = String(entry.dropLast(".json".count)) + guard let pid = Int32(basename) else { continue } + // kill(pid, 0) returns 0 iff signal would be delivered. A + // not-running PID surfaces ESRCH; lacking permission surfaces + // EPERM (process exists but isn't ours) — only ESRCH lets us + // confidently say "gone." + if kill(pid, 0) == 0 { continue } + if errno != ESRCH { continue } + let path = "\(dir)/\(entry)" + if let mtime = (try? fm.attributesOfItem(atPath: path))?[.modificationDate] as? Date, + now.timeIntervalSince(mtime) < minAge { + continue + } + try? fm.removeItem(atPath: path) + } + } + // Per-PID sidecar emitted by Claude Code (interactive runs) with the // session UUID, user-facing name, and live busy/idle status. Reading // this lets us bind a Session to its transcript immediately without diff --git a/panel/Updater.swift b/panel/Updater.swift index 7cf9f11..d5cf6a9 100644 --- a/panel/Updater.swift +++ b/panel/Updater.swift @@ -64,6 +64,11 @@ final class Updater { private weak var nav: PanelNav? private let session: URLSession + // Working directory under NSTemporaryDirectory() for the current run. + // Holds the downloaded tarball and the extracted .app until the swap + // moves the .app into ~/Applications/; cleanupTempDir() recycles it on + // both success and failure (run() defers the call). + private var tmpDir: URL? init(nav: PanelNav) { self.nav = nav @@ -89,6 +94,15 @@ final class Updater { DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard let self else { return } + // Sweep accumulated debris from past runs (failed updates, force- + // quit mid-update) so /tmp doesn't grow without bound. Cheap — + // we only touch our own UUID-namespaced directories. + Self.sweepStaleTempDirs() + // The defer fires before the closure exits, which is before + // scheduleAutoQuit's delayed terminate; the .app has already + // been moved out to ~/Applications/ by then, so we're only + // recycling the tarball and the empty tmpdir shell. + defer { self.cleanupTempDir() } do { try self.performUpdate() } catch { @@ -97,6 +111,32 @@ final class Updater { } } + // Remove the current run's tmpdir (downloaded tarball + any leftover + // extracted files). Idempotent: no-op when tmpDir is nil or already + // gone. Called from run()'s defer block so both success and failure + // paths converge here. + private func cleanupTempDir() { + guard let dir = tmpDir else { return } + try? FileManager.default.removeItem(at: dir) + tmpDir = nil + } + + // Recycle any stack-nudge-update-* directory under NSTemporaryDirectory() + // left behind by a prior run that crashed, was force-quit, or failed + // before our defer-cleanup landed. Bounded by what's in /tmp; safe to + // call on every run() because the directories are UUID-namespaced and + // ours alone. + private static func sweepStaleTempDirs() { + let fm = FileManager.default + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + guard let entries = try? fm.contentsOfDirectory(at: root, + includingPropertiesForKeys: nil) + else { return } + for entry in entries where entry.lastPathComponent.hasPrefix("stack-nudge-update-") { + try? fm.removeItem(at: entry) + } + } + // MARK: - Pipeline private func performUpdate() throws { @@ -302,12 +342,15 @@ final class Updater { // Write to a stable temp path so the rest of the pipeline can run // tar/xattr against it. - let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory()) + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("stack-nudge-update-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: tmpDir, + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - let dest = tmpDir.appendingPathComponent("stack-nudge.tar.gz") + // Record on the instance so cleanupTempDir() can recycle it once + // performUpdate() returns (success or failure). + self.tmpDir = dir + let dest = dir.appendingPathComponent("stack-nudge.tar.gz") try data.write(to: dest) return dest } @@ -400,8 +443,10 @@ final class Updater { // 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. + // installed app. The .old bundle stays on disk until the new bundle's + // applicationDidFinishLaunching runs Bootstrap.cleanupPostUpdateBackup + // — that's intentional: it's a rollback safety net for the failure + // window between swap and successful launch. private func atomicSwap(extractedAppURL: URL) throws { let fm = FileManager.default let target = URL(fileURLWithPath: Self.installedAppPath)