diff --git a/Tests/StackNudgePanelCoreTests/EventListenerTests.swift b/Tests/StackNudgePanelCoreTests/EventListenerTests.swift new file mode 100644 index 0000000..a88d49b --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/EventListenerTests.swift @@ -0,0 +1,118 @@ +import XCTest + +@testable import StackNudgePanelCore + +// Pure-logic tests for the wire-format parser. The socket I/O itself is +// exercised by the running app; what regresses silently is the +// newline-split + DTO decode pipeline. A malformed line should not take +// down the whole batch; missing optional fields should default cleanly. +final class EventListenerTests: XCTestCase { + + private func payload(_ json: String) -> Data { + Data(json.utf8) + } + + // MARK: - Single events + + func test_parseEvents_decodesMinimalEvent() { + let data = payload(#""" + {"agent":"claude-code","event":"stop","title":"Done","message":"All set"} + """#) + let events = EventListener.parseEvents(data) + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events[0].agent, "claude-code") + XCTAssertEqual(events[0].kind, .stop) + XCTAssertEqual(events[0].title, "Done") + XCTAssertEqual(events[0].message, "All set") + } + + func test_parseEvents_unknownKindFallsBackToOther() { + let data = payload(#""" + {"agent":"claude-code","event":"future_kind","title":"x","message":"y"} + """#) + let events = EventListener.parseEvents(data) + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events[0].kind, .other) + } + + func test_parseEvents_populatesOptionalEnrichmentFields() { + // Must stay on a single line — parseEvents splits on \n before + // decoding, so any embedded newlines in the JSON payload would + // shred it into malformed fragments and the test would observe + // zero events. + let data = payload(#"{"agent":"claude-code","event":"permission","title":"Allow?","message":"rm -rf","project_path":"/repo","bundle_id":"com.googlecode.iterm2","window_title":"agent: repo","session_id":"w0t0p0","term_program":"iTerm.app","claude_session_id":"abc-123","transcript_path":"/tmp/t.jsonl","sound_name":"Ping","voice_message":"Please review","bypass_mute":true,"has_action_button":true}"#) + let events = EventListener.parseEvents(data) + XCTAssertEqual(events.count, 1) + let e = events[0] + XCTAssertEqual(e.projectPath, "/repo") + XCTAssertEqual(e.bundleID, "com.googlecode.iterm2") + XCTAssertEqual(e.windowTitle, "agent: repo") + XCTAssertEqual(e.sessionID, "w0t0p0") + XCTAssertEqual(e.termProgram, "iTerm.app") + XCTAssertEqual(e.claudeSessionID, "abc-123") + XCTAssertEqual(e.transcriptPath, "/tmp/t.jsonl") + XCTAssertEqual(e.soundName, "Ping") + XCTAssertEqual(e.voiceMessage, "Please review") + XCTAssertTrue(e.bypassMute) + XCTAssertTrue(e.hasActionButton) + } + + func test_parseEvents_missingTimestampDefaultsToNow() { + let before = Date() + let data = payload(#""" + {"agent":"a","event":"stop","title":"t","message":"m"} + """#) + let events = EventListener.parseEvents(data) + let after = Date() + XCTAssertEqual(events.count, 1) + XCTAssertGreaterThanOrEqual(events[0].timestamp, before) + XCTAssertLessThanOrEqual(events[0].timestamp, after) + } + + func test_parseEvents_explicitTimestampPreserved() { + let data = payload(#""" + {"agent":"a","event":"stop","title":"t","message":"m","timestamp":1700000000} + """#) + let events = EventListener.parseEvents(data) + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events[0].timestamp.timeIntervalSince1970, 1700000000, accuracy: 0.001) + } + + // MARK: - Batching + recovery + + func test_parseEvents_handlesMultipleLines() { + let line1 = #"{"agent":"a","event":"stop","title":"t1","message":"m1"}"# + let line2 = #"{"agent":"b","event":"permission","title":"t2","message":"m2"}"# + let data = payload("\(line1)\n\(line2)\n") + let events = EventListener.parseEvents(data) + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events[0].title, "t1") + XCTAssertEqual(events[1].title, "t2") + } + + func test_parseEvents_keepsTrailingLineWithoutNewline() { + let line1 = #"{"agent":"a","event":"stop","title":"first","message":"m"}"# + let line2 = #"{"agent":"b","event":"stop","title":"second","message":"m"}"# + // Deliberately no trailing newline on the last line — notify.sh + // doesn't always terminate. + let data = payload("\(line1)\n\(line2)") + let events = EventListener.parseEvents(data) + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events[1].title, "second") + } + + func test_parseEvents_dropsMalformedLineButKeepsRest() { + let good1 = #"{"agent":"a","event":"stop","title":"good1","message":"m"}"# + let bad = #"{not valid json"# + let good2 = #"{"agent":"a","event":"stop","title":"good2","message":"m"}"# + let data = payload("\(good1)\n\(bad)\n\(good2)\n") + let events = EventListener.parseEvents(data) + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events.map(\.title), ["good1", "good2"]) + } + + func test_parseEvents_returnsEmptyOnAllBlank() { + XCTAssertEqual(EventListener.parseEvents(Data()).count, 0) + XCTAssertEqual(EventListener.parseEvents(payload("\n\n\n")).count, 0) + } +} diff --git a/Tests/StackNudgePanelCoreTests/UpdateCheckerTests.swift b/Tests/StackNudgePanelCoreTests/UpdateCheckerTests.swift new file mode 100644 index 0000000..236a6d5 --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/UpdateCheckerTests.swift @@ -0,0 +1,101 @@ +import XCTest + +@testable import StackNudgePanelCore + +// Pure-logic tests for the version-comparison and release-asset matching +// helpers. UpdateChecker's network paths are exercised manually; the +// regressable pieces are these static helpers, which gate when the +// "Update available" badge appears and which artifact we try to install. +final class UpdateCheckerTests: XCTestCase { + + // MARK: - stripV + + func test_stripV_removesLeadingV() { + XCTAssertEqual(UpdateChecker.stripV("v1.2.3"), "1.2.3") + } + + func test_stripV_isNoOpWithoutPrefix() { + XCTAssertEqual(UpdateChecker.stripV("1.2.3"), "1.2.3") + } + + func test_stripV_doesNotStripMidString() { + XCTAssertEqual(UpdateChecker.stripV("1.v2.3"), "1.v2.3") + } + + // MARK: - isNewer + + func test_isNewer_returnsTrueOnHigherPatch() { + XCTAssertTrue(UpdateChecker.isNewer("1.2.4", than: "1.2.3")) + } + + func test_isNewer_returnsFalseOnEqual() { + XCTAssertFalse(UpdateChecker.isNewer("1.2.3", than: "1.2.3")) + } + + func test_isNewer_returnsFalseOnLower() { + XCTAssertFalse(UpdateChecker.isNewer("1.2.2", than: "1.2.3")) + } + + // The classic semver pitfall: "1.10" must beat "1.9" numerically, + // not lexicographically. A regression here means users on 1.9 never + // see the 1.10 update. + func test_isNewer_comparesComponentsNumericallyNotLexically() { + XCTAssertTrue(UpdateChecker.isNewer("1.10.0", than: "1.9.0")) + XCTAssertTrue(UpdateChecker.isNewer("0.20.0", than: "0.3.0")) + } + + func test_isNewer_handlesAsymmetricComponentCounts() { + // "2.0" vs "2.0.0" should treat as equal (missing trailing zero). + XCTAssertFalse(UpdateChecker.isNewer("2.0", than: "2.0.0")) + XCTAssertFalse(UpdateChecker.isNewer("2.0.0", than: "2.0")) + XCTAssertTrue(UpdateChecker.isNewer("2.0.1", than: "2.0")) + } + + func test_isNewer_handlesMinorBump() { + XCTAssertTrue(UpdateChecker.isNewer("1.16.0", than: "1.15.5")) + } + + // MARK: - hasArtifact + + private func release(assetNames: [String]) -> [String: Any] { + ["assets": assetNames.map { ["name": $0] as [String: Any] }] + } + + func test_hasArtifact_findsMatchingArm64() { + let json = release(assetNames: [ + "stack-nudge-1.16.1-macos-arm64.tar.gz", + "stack-nudge-1.16.1-macos-arm64.tar.gz.sha256", + "stack-nudge-1.16.1-macos-x86_64.tar.gz", + ]) + XCTAssertTrue(UpdateChecker.hasArtifact(in: json, arch: "arm64")) + XCTAssertTrue(UpdateChecker.hasArtifact(in: json, arch: "x86_64")) + } + + // The pre-CI-build window: release-please created the tag but the + // build/upload step hasn't finished. Asset list is empty (or only has + // unrelated files). Badge must stay hidden. + func test_hasArtifact_returnsFalseWhenAssetsEmpty() { + XCTAssertFalse(UpdateChecker.hasArtifact(in: ["assets": [[String: Any]]()], arch: "arm64")) + } + + func test_hasArtifact_returnsFalseWhenAssetsMissing() { + XCTAssertFalse(UpdateChecker.hasArtifact(in: [:], arch: "arm64")) + XCTAssertFalse(UpdateChecker.hasArtifact(in: nil, arch: "arm64")) + } + + // sha256 sidecar must not be mistaken for the tarball — they share + // the arch suffix as a substring. + func test_hasArtifact_ignoresShaSidecarOnly() { + let json = release(assetNames: [ + "stack-nudge-1.16.1-macos-arm64.tar.gz.sha256", + ]) + XCTAssertFalse(UpdateChecker.hasArtifact(in: json, arch: "arm64")) + } + + func test_hasArtifact_returnsFalseWhenOnlyOtherArchPresent() { + let json = release(assetNames: [ + "stack-nudge-1.16.1-macos-arm64.tar.gz", + ]) + XCTAssertFalse(UpdateChecker.hasArtifact(in: json, arch: "x86_64")) + } +} diff --git a/build.sh b/build.sh index 94fa163..d636c1f 100755 --- a/build.sh +++ b/build.sh @@ -212,6 +212,12 @@ sign_venv_contents() { echo "Building stack-nudge ($ARCH)..." rm -rf build +mkdir -p build +# Exclude the dev build from Spotlight so re-builds don't surface a stale +# StackNudge.app duplicate next to ~/Applications/StackNudge.app in search +# results. Touched here (not committed) because `rm -rf build` wipes it +# every run. +touch build/.metadata_never_index build_app "$APP" "stack-nudge" \ "panel/Info.plist" "notifier/Icon.icns" "13.0" \ diff --git a/panel/CompactView.swift b/panel/CompactView.swift index ed1f4f6..4d32db1 100644 --- a/panel/CompactView.swift +++ b/panel/CompactView.swift @@ -20,9 +20,15 @@ struct CompactView: View { @State private var rippleScale: CGFloat = 0.3 @State private var rippleOpacity: Double = 0 @State private var isHovering: Bool = false + // Rotating index into `activeSessions` for the cycling display. Only + // advances when `shouldCycle` is true (≥2 active sessions and nothing + // more urgent on screen). Stays bounded by % so a session disappearing + // mid-rotation doesn't crash. + @State private var cycleIndex: Int = 0 private static let glowColor = Color(red: 0.4, green: 0.85, blue: 1.0) private static let recentEventWindow: TimeInterval = 5 * 60 + private static let cyclePeriod: TimeInterval = 5.0 var body: some View { HStack(spacing: 10) { @@ -160,6 +166,8 @@ struct CompactView: View { .lineLimit(1) } } + } else if shouldCycle { + cyclingActiveSession } else if let active = mostRecentActive { Text(displayName(active)) .font(.system(size: 11, weight: .medium)) @@ -172,6 +180,50 @@ struct CompactView: View { } } + // When ≥2 sessions are active and no busy session / recent event is + // demanding the spotlight, cycle through each active session every + // `cyclePeriod` seconds so the user can see all of their parallel + // agents at a glance instead of just one. Soft crossfade between + // entries. Snap-back to the single-session display is automatic — + // `shouldCycle` flips false the moment busy or recent-event branches + // take over, SwiftUI tears down this view, and the Timer.publish + // subscription cancels with it. + @ViewBuilder + private var cyclingActiveSession: some View { + let pool = activeSessions + let session = pool[cycleIndex % max(pool.count, 1)] + HStack(spacing: 4) { + Text(displayName(session)) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + Text("·") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + if let stats = transcriptStats(for: session) { + Text(Self.formatTokens(stats.tokens)) + .font(.system(size: 10, weight: .medium).monospacedDigit()) + .foregroundStyle(.secondary) + } else { + let position = (pool.firstIndex { $0.pid == session.pid } ?? 0) + 1 + Text("\(position)/\(pool.count)") + .font(.system(size: 10).monospacedDigit()) + .foregroundStyle(.tertiary) + } + } + .id(session.pid) + .transition(.opacity) + .onReceive(Timer.publish(every: Self.cyclePeriod, on: .main, in: .common).autoconnect()) { _ in + // Re-check shouldCycle on every tick — pool size can change as + // sessions finish or new ones start. Skip during drag so we + // don't compete with AppKit's mouse handler. + guard shouldCycle, !nav.compactDragging else { return } + withAnimation(.easeInOut(duration: 0.45)) { + cycleIndex = (cycleIndex + 1) % max(activeSessions.count, 1) + } + } + } + // 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 @@ -313,6 +365,22 @@ struct CompactView: View { sessions.sessions.first { $0.status == .active } } + // Stable, PID-sorted list of every currently-active session. Drives + // the pill's rotation when ≥2 are alive at the same time. + private var activeSessions: [Session] { + sessions.sessions + .filter { $0.status == .active } + .sorted { $0.pid < $1.pid } + } + + // Rotate sessions only when nothing more important is on screen and + // there's actually something to rotate through. + private var shouldCycle: Bool { + busiestSession == nil + && recentEvent == nil + && activeSessions.count >= 2 + } + private func transcriptStats(for s: Session) -> TranscriptStats? { if let id = s.claudeSessionID, let stats = nav.claudeSessionStats[id] { return stats diff --git a/panel/EventListener.swift b/panel/EventListener.swift index a4025f6..3ed7989 100644 --- a/panel/EventListener.swift +++ b/panel/EventListener.swift @@ -15,6 +15,11 @@ final class EventListener { private var serverFD: Int32 = -1 private let queue = DispatchQueue(label: "stack-nudge.panel.listener") private let decoder = JSONDecoder() + // Inode of the socket file we created in start(). Checked again in + // stop() so we only unlink when the file on disk is still the one we + // bound to — protects against the post-update race where the old + // bundle's shutdown otherwise removes the new bundle's socket file. + private var boundInode: ino_t? init(store: EventStore, socketPath: String) { self.store = store @@ -55,6 +60,14 @@ final class EventListener { chmod(socketPath, 0o600) + // Stat after bind so stop() can verify we still own this file + // before unlinking it. Avoids the post-update race where the old + // bundle's terminate handler removes the new bundle's socket. + var st = stat() + if stat(socketPath, &st) == 0 { + boundInode = st.st_ino + } + if listen(serverFD, 16) != 0 { let err = errno close(serverFD); serverFD = -1 @@ -66,6 +79,13 @@ final class EventListener { func stop() { if serverFD >= 0 { close(serverFD); serverFD = -1 } + // Only remove the file if it's still the one we bound to. If a + // successor process (post-update relaunch) has already replaced + // it, our unlink would wipe their freshly-bound socket and break + // event delivery for them. + guard let want = boundInode else { return } + var st = stat() + guard stat(socketPath, &st) == 0, st.st_ino == want else { return } unlink(socketPath) } @@ -93,12 +113,7 @@ final class EventListener { } guard !buffer.isEmpty else { return } - for line in buffer.split(separator: 0x0A) { - guard !line.isEmpty else { continue } - guard let dto = try? decoder.decode(NudgeEventDTO.self, from: line) else { - continue - } - let event = dto.toNudgeEvent() + for event in Self.parseEvents(buffer) { // Teach the VSCode integration about this event's window // before we dispatch — the Sessions tab's next poll will // pick up the new (ipcHook → window title) pairing. @@ -114,6 +129,23 @@ final class EventListener { } } } + + // Pure parser: newline-split the wire buffer and decode each non-empty + // line as a NudgeEvent. Malformed lines are silently skipped (drop the + // bad line, keep the rest) — handleClient already enforced the payload + // size limit upstream. Exposed for tests. + static func parseEvents(_ buffer: Data) -> [NudgeEvent] { + let decoder = JSONDecoder() + var events: [NudgeEvent] = [] + for line in buffer.split(separator: 0x0A) { + guard !line.isEmpty else { continue } + guard let dto = try? decoder.decode(NudgeEventDTO.self, from: line) else { + continue + } + events.append(dto.toNudgeEvent()) + } + return events + } } private struct NudgeEventDTO: Decodable { diff --git a/panel/UpdateChecker.swift b/panel/UpdateChecker.swift index a0fa2cd..f6e904c 100644 --- a/panel/UpdateChecker.swift +++ b/panel/UpdateChecker.swift @@ -75,14 +75,55 @@ final class UpdateChecker { let latest = Self.stripV(tag) let newer = Self.isNewer(latest, than: current) let body = json?["body"] as? String + // release-please creates the GitHub Release the moment its PR + // merges, but release.yml takes 5–15 min more to build, sign, + // notarize, and upload the per-arch .tar.gz. Without an artifact + // present, clicking "Update" downstream fails with + // noArtifactForArch. Suppress the badge until the matching + // artifact actually exists on the release. + let artifactReady = newer && Self.hasArtifactForThisHost(in: json) DispatchQueue.main.async { - self?.nav?.updateAvailable = newer ? latest : nil - self?.nav?.updateReleaseNotes = newer ? body : nil - completion?(newer ? .updateAvailable(latest) : .upToDate) + self?.nav?.updateAvailable = artifactReady ? latest : nil + self?.nav?.updateReleaseNotes = artifactReady ? body : nil + if newer && !artifactReady { + // upToDate from the user's perspective right now — + // they'll see the badge once CI finishes uploading. + completion?(.upToDate) + } else { + completion?(artifactReady ? .updateAvailable(latest) : .upToDate) + } } } } + // True when the release JSON's `assets[]` includes a `.tar.gz` matching + // this host's architecture. Mirrors Updater.currentArch — we look for + // "-macos-arm64.tar.gz" or "-macos-x86_64.tar.gz" depending on uname. + private static func hasArtifactForThisHost(in json: [String: Any]?) -> Bool { + hasArtifact(in: json, arch: currentHostArch()) + } + + // Parameterized variant exposed for tests so we can exercise both + // arm64 and x86_64 paths from a single host without spoofing uname. + static func hasArtifact(in json: [String: Any]?, arch: String) -> Bool { + guard let assets = json?["assets"] as? [[String: Any]] else { return false } + let suffix = "-macos-\(arch).tar.gz" + return assets.contains { asset in + guard let name = asset["name"] as? String else { return false } + return name.hasSuffix(suffix) && !name.hasSuffix(".sha256") + } + } + + private static func currentHostArch() -> String { + var sysinfo = utsname() + uname(&sysinfo) + let m = withUnsafePointer(to: &sysinfo.machine) { + $0.withMemoryRebound(to: CChar.self, capacity: 1) { String(cString: $0) } + } + // uname returns "arm64" / "x86_64" on macOS. + return m == "x86_64" ? "x86_64" : "arm64" + } + // One-shot fetch of release notes for the update-confirm view. Used when // the user clicks the update row before the background check has cached // notes. Calls completion on the main thread with the body string or nil.