From 0ce9dae94f25dcac9ed28b8bec4e89e5f68dc201 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 16 Aug 2026 13:16:23 -0400 Subject: [PATCH] =?UTF-8?q?The=20room-guest=20light=20learns=20which=20ses?= =?UTF-8?q?sion=20it=20lives=20in:=20distnoted=20doesn't=20reliably=20carr?= =?UTF-8?q?y=20a=20distributed=20notification=20from=20an=20ssh=20login=20?= =?UTF-8?q?session=20into=20the=20Aqua=20session=20=E2=80=94=20which=20is?= =?UTF-8?q?=20exactly=20where=20a=20room=20receiver=20runs=20=E2=80=94=20s?= =?UTF-8?q?o=20the=20CLI=20now=20heartbeats=20/tmp/ac-room-guest.json=20ev?= =?UTF-8?q?ery=205=20s=20(unlinked=20on=20exit,=20staleness=20covers=20SIG?= =?UTF-8?q?KILL)=20and=20Menu=20Band=20polls=20the=20file's=20freshness=20?= =?UTF-8?q?instead=20of=20listening=20for=20a=20post:=20fresh=20beacon=20s?= =?UTF-8?q?pins=20the=20Juke=20disc=20as=20'room=20RIGHT=20=E2=86=90=20neo?= =?UTF-8?q?'=20and=20fires=20one=20banner=20per=20engagement=20announcing?= =?UTF-8?q?=20which=20channel=20this=20Mac=20just=20became?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/ACAudioRoomCLI/main.swift | 42 +++++----- .../Sources/MenuBand/AppDelegate.swift | 77 ++++++++++--------- 2 files changed, 62 insertions(+), 57 deletions(-) diff --git a/slab/macos-audio/Sources/ACAudioRoomCLI/main.swift b/slab/macos-audio/Sources/ACAudioRoomCLI/main.swift index 7bafffccae..017dcdb359 100644 --- a/slab/macos-audio/Sources/ACAudioRoomCLI/main.swift +++ b/slab/macos-audio/Sources/ACAudioRoomCLI/main.swift @@ -26,16 +26,18 @@ private func emit(_ line: String) { // Room-guest beacon: a receiver announces itself to the LOCAL Menu Band so // its Juke disc can spin while this Mac carries a channel of another Mac's -// room. Globals because the atexit/signal paths can't capture context. -var roomGuestInfo: (host: String, channel: String)? -func postRoomGuest(active: Bool) { - guard let info = roomGuestInfo else { return } - DistributedNotificationCenter.default().postNotificationName( - NSNotification.Name("computer.aestheticcomputer.menuband.roomGuest"), - object: nil, - userInfo: ["active": active ? "1" : "0", - "host": info.host, "channel": info.channel], - deliverImmediately: true) +// room. A FILE, not a distributed notification: receivers are usually +// ssh-spawned, and distnoted does not reliably deliver from a login +// session into the Aqua session. Menu Band polls the file's freshness. +// Global because the atexit path can't capture context. +let roomGuestBeacon = "/tmp/ac-room-guest.json" +var roomGuestPayload: String? +func writeRoomGuestBeacon() { + guard let payload = roomGuestPayload else { return } + try? payload.write(toFile: roomGuestBeacon, atomically: true, encoding: .utf8) +} +func clearRoomGuestBeacon() { + if roomGuestPayload != nil { unlink(roomGuestBeacon) } } let args = Array(CommandLine.arguments.dropFirst()) @@ -58,19 +60,21 @@ do { receiver.onLog = { emit("room receive · \($0)") } receiver.onStarved = { emit("room receive · sender lost — exiting") - postRoomGuest(active: false) + clearRoomGuestBeacon() exit(0) } try receiver.start() - // Announce to the local Menu Band: start, 30 s heartbeat (covers a - // SIGKILL that skips the goodbye), and a best-effort exit goodbye. - roomGuestInfo = (host.replacingOccurrences(of: ".local", with: ""), - channel.name) - postRoomGuest(active: true) - atexit { postRoomGuest(active: false) } + // Announce to the local Menu Band: beacon on start, touched every + // 5 s (freshness = liveness, survives SIGKILL via staleness), and a + // best-effort unlink on exit. + let shortHost = host.replacingOccurrences(of: ".local", with: "") + roomGuestPayload = + "{\"host\": \"\(shortHost)\", \"channel\": \"\(channel.name)\", \"pid\": \(getpid())}" + writeRoomGuestBeacon() + atexit { clearRoomGuestBeacon() } let heartbeat = DispatchSource.makeTimerSource() - heartbeat.schedule(deadline: .now() + 30, repeating: 30) - heartbeat.setEventHandler { postRoomGuest(active: true) } + heartbeat.schedule(deadline: .now() + 5, repeating: 5) + heartbeat.setEventHandler { writeRoomGuestBeacon() } heartbeat.resume() dispatchMain() diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index 4ad7744cca..9122f495f5 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -1394,16 +1394,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ) #if !MAC_APP_STORE - // A room-audio receiver on THIS Mac (the ac-audio-room CLI, spawned - // by another Mac's Juke) announces itself so the Juke disc spins - // while this machine carries a channel of someone else's room — - // the "part of the performance" light. - DistributedNotificationCenter.default().addObserver( - self, - selector: #selector(handleRoomGuestNotification(_:)), - name: NSNotification.Name("computer.aestheticcomputer.menuband.roomGuest"), - object: nil - ) + // A room-audio receiver on THIS Mac (the ac-audio-room CLI, usually + // ssh-spawned by another Mac's Juke) heartbeats a beacon file — + // distnoted doesn't reliably cross from a login session into the + // Aqua session, so liveness is the file's freshness. While fresh, + // the Juke disc spins as the "part of the performance" light. + Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { + [weak self] _ in self?.pollRoomGuestBeacon() + } #endif // Live engine: a conductible drone/arp/drum loop that runs @@ -4312,37 +4310,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// rendering a channel of another Mac's room) rather than a real local /// Juke session — so we only ever hide what we ourselves showed. private var roomGuestShowing = false - private var roomGuestExpiry: Timer? - @objc private func handleRoomGuestNotification(_ note: Notification) { - let info = note.userInfo as? [String: String] ?? [:] - DispatchQueue.main.async { [weak self] in - guard let self, let item = self.jukeStatusItem else { return } - if info["active"] == "1" { - // Never cover a real Juke display with the guest badge. - guard self.roomGuestShowing || !item.isShowing else { return } - self.roomGuestShowing = true - let channel = info["channel"] ?? "guest" - let host = info["host"] ?? "room" - item.update(title: "room \(channel) ← \(host)", artist: "", - disc: nil, playing: true) - // The receiver heartbeats every 30 s; three missed beats - // (SIGKILL, network gone) and the badge retires itself. - self.roomGuestExpiry?.invalidate() - self.roomGuestExpiry = Timer.scheduledTimer( - withTimeInterval: 90, repeats: false - ) { [weak self] _ in - guard let self, self.roomGuestShowing else { return } - self.roomGuestShowing = false - self.jukeStatusItem?.hide() - } - } else if self.roomGuestShowing { - self.roomGuestShowing = false - self.roomGuestExpiry?.invalidate() - self.roomGuestExpiry = nil - item.hide() + /// The ac-audio-room receiver touches /tmp/ac-room-guest.json every 5 s + /// while it renders; fresh file = this Mac is someone's channel. Stale + /// or missing (SIGKILL, network gone, room closed) and the badge + /// retires itself within a poll or two. + private func pollRoomGuestBeacon() { + let path = "/tmp/ac-room-guest.json" + let fm = FileManager.default + guard let attrs = try? fm.attributesOfItem(atPath: path), + let modified = attrs[.modificationDate] as? Date, + Date().timeIntervalSince(modified) < 12, + let data = fm.contents(atPath: path), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + if roomGuestShowing { + roomGuestShowing = false + jukeStatusItem?.hide() } + return } + guard let item = jukeStatusItem else { return } + // Never cover a real Juke display with the guest badge. + guard roomGuestShowing || !item.isShowing else { return } + let channel = (obj["channel"] as? String ?? "guest").uppercased() + let host = obj["host"] as? String ?? "room" + if !roomGuestShowing { + roomGuestShowing = true + // One banner per engagement: which channel this Mac just became. + let banner = NSUserNotification() + banner.title = "Room audio engaged" + banner.informativeText = "This Mac is the \(channel) channel of \(host)'s room." + NSUserNotificationCenter.default.deliver(banner) + } + item.update(title: "room \(channel) ← \(host)", artist: "", + disc: nil, playing: true) } #endif -- 2.51.2