From 9643a3432e67c5d515b117dc1fd2e3f48902fdf1 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 9 Jun 2026 09:26:40 +0100 Subject: [PATCH 1/5] fix(build): preserve Spotlight exclusion marker across rebuilds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build.sh starts with `rm -rf build`, which wipes any previously-placed `.metadata_never_index` marker. After the next rebuild, Spotlight reindexes `build/StackNudge.app` and the user sees two StackNudge entries — the installed bundle in ~/Applications and the dev artifact — which is confusing right after an update. Re-create the marker every build so the dev artifact stays out of Spotlight regardless of how often we rebuild. --- build.sh | 6 ++++++ 1 file changed, 6 insertions(+) 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" \ From 6c4b723d33b97de73d8db44e012569de0c16868f Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 9 Jun 2026 10:22:58 +0100 Subject: [PATCH 2/5] fix(listener): inode-guard socket unlink against post-update race Auto-update relaunch race: new bundle's EventListener.start() unlinked any stale socket, bound, and created the file. The old bundle's applicationWillTerminate then ran listener.stop() and unlinked the socket file out from under the new bundle, breaking event delivery for the rest of the new bundle's lifetime. Symptom: after update, no events land in the panel (no banners, no sounds, no Events tab activity) until the panel is manually restarted. Record the bound inode after start() succeeds; stop() now only unlinks if the file on disk is still the one we bound to. A successor process that has already replaced the socket keeps its file intact. --- panel/EventListener.swift | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/panel/EventListener.swift b/panel/EventListener.swift index a4025f6..d5d87fe 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) } From df3ceab8aba0061f1df78e113532d3a6a5cd2ee2 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 9 Jun 2026 11:43:04 +0100 Subject: [PATCH 3/5] fix(widget): cycle through active sessions; suppress pre-build update badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes in the user-facing update + pill loop: - Pill: the headline ladder pinned to a single session (busiest → recent event → mostRecentActive). Users with 2+ parallel agents only saw the most-recent-active session and lost sight of the others until opening the panel. Insert a new branch — when ≥2 sessions are active and nothing more urgent is on screen, rotate through them every 5s with a soft crossfade. Snap back to the busy/event display the moment one takes priority; SwiftUI tearing down the view auto-cancels the timer. Pause cycling during drag so it doesn't compete with AppKit's handler. - UpdateChecker: release-please creates the GitHub Release the moment its PR merges, but the release.yml build/sign/notarize/upload runs for 5–15 minutes after. During that window UpdateChecker advertised the new version but Updater couldn't find a matching .tar.gz and failed with noArtifactForArch. Now check the release's assets[] for -macos-.tar.gz before flipping nav.updateAvailable, so the badge only appears once the artifact is actually downloadable. The user just sees "up to date" until CI finishes. --- panel/CompactView.swift | 68 +++++++++++++++++++++++++++++++++++++++ panel/UpdateChecker.swift | 42 ++++++++++++++++++++++-- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/panel/CompactView.swift b/panel/CompactView.swift index 3e8f8db..a21e462 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 @@ -309,6 +361,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/UpdateChecker.swift b/panel/UpdateChecker.swift index a0fa2cd..7da5510 100644 --- a/panel/UpdateChecker.swift +++ b/panel/UpdateChecker.swift @@ -75,14 +75,50 @@ 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 { + guard let assets = json?["assets"] as? [[String: Any]] else { return false } + let arch = currentHostArch() + 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. From 5d942aba1e3c8bab3a34db94625de49755be6e37 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 9 Jun 2026 11:55:11 +0100 Subject: [PATCH 4/5] test(core): cover UpdateChecker version+artifact logic and EventListener parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pure-logic surfaces with high blast radius had no test coverage: - UpdateChecker.isNewer / .stripV / artifact-matching: a regression here silently misroutes auto-update — users either never see the badge or click Update and hit noArtifactForArch. Cover the classic semver pitfall (1.10 > 1.9 numerically), the pre-CI-build window where the tag exists but the .tar.gz hasn't uploaded, and sha256-sidecar false-positives. - EventListener wire parser: malformed JSON, missing optional fields, multi-line batches without trailing newline, mid-batch recovery. This is the most-traffic I/O path in the app — every notify.sh event passes through it. Small enabling refactors: - UpdateChecker: extract `hasArtifact(in:arch:)` from `hasArtifactForThisHost` so tests can exercise both archs without spoofing uname. - EventListener: extract `parseEvents(_:) -> [NudgeEvent]` from `handleClient` so the parse pipeline is testable without spinning up a socket. Skipped SessionStore.merge for now — `private` + needs deep `Session` scaffolding; revisit in a follow-up. --- .../EventListenerTests.swift | 120 ++++++++++++++++++ .../UpdateCheckerTests.swift | 101 +++++++++++++++ panel/EventListener.swift | 24 +++- panel/UpdateChecker.swift | 7 +- 4 files changed, 245 insertions(+), 7 deletions(-) create mode 100644 Tests/StackNudgePanelCoreTests/EventListenerTests.swift create mode 100644 Tests/StackNudgePanelCoreTests/UpdateCheckerTests.swift diff --git a/Tests/StackNudgePanelCoreTests/EventListenerTests.swift b/Tests/StackNudgePanelCoreTests/EventListenerTests.swift new file mode 100644 index 0000000..8bab12f --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/EventListenerTests.swift @@ -0,0 +1,120 @@ +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() { + 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/panel/EventListener.swift b/panel/EventListener.swift index d5d87fe..3ed7989 100644 --- a/panel/EventListener.swift +++ b/panel/EventListener.swift @@ -113,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. @@ -134,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 7da5510..f6e904c 100644 --- a/panel/UpdateChecker.swift +++ b/panel/UpdateChecker.swift @@ -100,8 +100,13 @@ final class UpdateChecker { // 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 arch = currentHostArch() let suffix = "-macos-\(arch).tar.gz" return assets.contains { asset in guard let name = asset["name"] as? String else { return false } From 9250a6b993706026d7cde97c22dfb4123c29ec15 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 9 Jun 2026 16:37:13 +0100 Subject: [PATCH 5/5] test(listener): collapse multi-line JSON payload onto one line parseEvents splits on \n before decoding, so a multi-line raw-string JSON payload shreds into malformed fragments and the test sees zero events. Reflow as one line. --- .../EventListenerTests.swift | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/Tests/StackNudgePanelCoreTests/EventListenerTests.swift b/Tests/StackNudgePanelCoreTests/EventListenerTests.swift index 8bab12f..a88d49b 100644 --- a/Tests/StackNudgePanelCoreTests/EventListenerTests.swift +++ b/Tests/StackNudgePanelCoreTests/EventListenerTests.swift @@ -36,13 +36,11 @@ final class EventListenerTests: XCTestCase { } func test_parseEvents_populatesOptionalEnrichmentFields() { - 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} - """#) + // 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]