diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index f7c9a8a0ca..85a7ef47e2 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -251,15 +251,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // session is non-stale; pulse phase always advances (it drives both // awaiting brightness and stale blink). When the polygon goes away // entirely, stop the timer and reset phases. - if !state.claudeSessions.isEmpty || state.messageWaiting { + if !state.claudeSessions.isEmpty || state.messageWaiting || state.idleResting { if animTimer == nil { let t = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in guard let self = self else { return } - self.rainbowPhase = (self.rainbowPhase + 0.025).truncatingRemainder(dividingBy: 1.0) + // Idle rests at a gentle hue flow; active states cycle + // faster (the awaiting throb reads off this phase). + let rainbowStep: CGFloat = self.state.idleResting ? 0.006 : 0.025 + self.rainbowPhase = (self.rainbowPhase + rainbowStep).truncatingRemainder(dividingBy: 1.0) if self.state.anyActive { let rotSpeed = 0.004 + 0.012 * CGFloat(self.state.awaitingCount) self.rotationPhase = (self.rotationPhase + rotSpeed) .truncatingRemainder(dividingBy: .pi * 2) + } else if self.state.idleResting { + // Slow spin — ~50 s per revolution. Enticing, not busy. + self.rotationPhase = (self.rotationPhase + 0.010) + .truncatingRemainder(dividingBy: .pi * 2) } self.updateIcon() } @@ -402,6 +409,66 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { ShellRunner.run("/usr/bin/open", args: ["-t", Paths.imsgConfig]) } + // MARK: - Deskflow KVM + + /// Read the launchd agent label from the untracked deskflow config. + /// Machine role/label/agent live there, never in tracked code. + private func deskflowAgent() -> String? { + guard let data = FileManager.default.contents(atPath: Paths.deskflowConfig), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let agent = obj["agent"] as? String, !agent.isEmpty + else { return nil } + return agent + } + + private func deskflowPlistPath(_ agent: String) -> String { + "\(Paths.home)/Library/LaunchAgents/\(agent).plist" + } + + @objc func deskflowStart() { + guard let agent = deskflowAgent() else { return } + ShellRunner.runAsync("/bin/launchctl", + args: ["bootstrap", "gui/\(getuid())", deskflowPlistPath(agent)]) + } + + @objc func deskflowStop() { + guard let agent = deskflowAgent() else { return } + ShellRunner.runAsync("/bin/launchctl", args: ["bootout", "gui/\(getuid())/\(agent)"]) + } + + @objc func deskflowRestart() { + guard let agent = deskflowAgent() else { return } + // bootstrap first in case it isn't loaded, then kickstart -k to + // force a fresh respawn of the managed process. + let cmd = "/bin/launchctl bootstrap gui/\(getuid()) \(deskflowPlistPath(agent)) 2>/dev/null; " + + "/bin/launchctl kickstart -k gui/\(getuid())/\(agent)" + ShellRunner.runShellAsync(cmd) + } + + @objc func openDeskflowLog() { + ShellRunner.run("/usr/bin/open", args: ["-a", "Console", Paths.deskflowLog]) + } + + @objc func openDeskflowConfig() { + // Write an untracked stub if absent, then open it for editing. + let path = Paths.deskflowConfig + if !FileManager.default.fileExists(atPath: path) { + let dir = (path as NSString).deletingLastPathComponent + try? FileManager.default.createDirectory( + atPath: dir, withIntermediateDirectories: true) + let stub = """ + { + "enabled": false, + "role": "server", + "label": "Deskflow", + "agent": "computer.aesthetic.deskflow" + } + """ + try? stub.write(toFile: path, atomically: true, encoding: .utf8) + } + ShellRunner.run("/usr/bin/open", args: ["-t", path]) + } + @objc func openDaemonLog() { ShellRunner.run("/usr/bin/open", args: ["-a", "Console", Paths.lidLog]) } diff --git a/slab/menubar-swift/Sources/SlabMenubar/IconRenderer.swift b/slab/menubar-swift/Sources/SlabMenubar/IconRenderer.swift index 4c141b866e..a0db5b54d9 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/IconRenderer.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/IconRenderer.swift @@ -37,8 +37,12 @@ enum IconRenderer { name = "square.stack.3d.up.fill" weight = .semibold } else { - name = "square.stack.3d.up" - weight = .regular + // Pure idle — nothing open. Instead of a static glyph, draw a + // slowly-spinning line with a rainbow flowing along its length: + // an enticing "resting" state that rhymes with the single-session + // line (n==1) but in colour. Themed for the current menubar. + let dark = state.forceBright ? false : AppDelegate.isDarkAppearance() + return idleLineImage(phase: phase, rotation: rotation, dark: dark) } let config = NSImage.SymbolConfiguration(pointSize: 14, weight: weight, scale: .medium) @@ -180,6 +184,70 @@ enum IconRenderer { return img } + /// The idle "resting" icon — a single line through the center that spins + /// slowly while a rainbow flows along its length. Non-template (coloured), + /// 22×22 @2x like the polygon. `rotation` is the spin angle; `phase` + /// scrolls the hue. Saturation/brightness adapt to light vs dark so it + /// stays vivid on either menubar; a soft mid-line brightness taper makes + /// it glow in the middle and feather at the round-capped ends. + private static func idleLineImage(phase: CGFloat, rotation: CGFloat, dark: Bool) -> NSImage { + let pointsW: CGFloat = 22, pointsH: CGFloat = 22 + let scale: CGFloat = 2 + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(pointsW * scale), + pixelsHigh: Int(pointsH * scale), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { return fallbackImage() } + rep.size = NSSize(width: pointsW, height: pointsH) + + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + defer { NSGraphicsContext.restoreGraphicsState() } + + let cx = pointsW / 2.0, cy = pointsH / 2.0 + let radius: CGFloat = 9.5 + let lineWidth: CGFloat = 1.8 + let sat: CGFloat = dark ? 0.85 : 0.95 + let bri: CGFloat = dark ? 1.0 : 0.82 + + let dirX = cos(rotation), dirY = sin(rotation) + // Stroke the line as short round-capped sub-segments, each one step + // further along the spectrum, so the line itself is a small moving + // rainbow rather than a flat colour. + let steps = 20 + let hueSpread: CGFloat = 0.6 // fraction of the hue wheel spanned end-to-end + for i in 0.. NSMenuItem { + let d = state.deskflow + let roleTag = d.role.isEmpty ? "" : " (\(d.role))" + let dot = d.running ? "●" : "○" + let title = "\(dot) \(d.label)\(roleTag): \(d.running ? "running" : "stopped")" + let parent = NSMenuItem(title: title, action: nil, keyEquivalent: "") + // Tint the leading dot green when running, gray when stopped — same + // glance-vocabulary as the Claude session rows. + let attr = NSMutableAttributedString(string: title) + let dotColor = d.running + ? NSColor(deviceHue: 0.33, saturation: 0.70, brightness: 0.78, alpha: 1.0) + : NSColor(deviceWhite: 0.55, alpha: 1.0) + attr.addAttribute(.foregroundColor, value: dotColor, + range: NSRange(location: 0, length: 1)) + parent.attributedTitle = attr + + let sub = NSMenu() + let start = item("Start", selector: #selector(AppDelegate.deskflowStart), target: target) + start.isEnabled = !d.running + sub.addItem(start) + let stop = item("Stop", selector: #selector(AppDelegate.deskflowStop), target: target) + stop.isEnabled = d.running + sub.addItem(stop) + sub.addItem(item("Restart", selector: #selector(AppDelegate.deskflowRestart), target: target)) + sub.addItem(.separator()) + sub.addItem(item("Open Deskflow log", selector: #selector(AppDelegate.openDeskflowLog), target: target)) + sub.addItem(item("Edit Deskflow config", selector: #selector(AppDelegate.openDeskflowConfig), target: target)) + parent.submenu = sub + return parent + } + } diff --git a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift index a986c900e8..8e1b418e7f 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift @@ -32,6 +32,16 @@ enum Paths { static var imsgHelper: String { "\(slabBin)/imsg" } static var imsgConfig: String { "\(home)/.config/slab/imsg.json" } + /// Deskflow KVM management. Role ("server"/"client"), display label, and + /// the launchd agent name live in untracked config so machine roles never + /// appear in tracked code (same convention as imsgConfig). JSON shape: + /// { "enabled": true, "role": "server", "label": "Deskflow", + /// "agent": "computer.aesthetic.deskflow" } + static var deskflowConfig: String { "\(home)/.config/slab/deskflow.json" } + /// Where the Deskflow server/client LaunchAgent writes its log (matches + /// the StandardOutPath in computer.aesthetic.deskflow.plist). + static var deskflowLog: String { "\(home)/Library/Logs/deskflow-core.log" } + /// Extra mbsync accounts beyond the AC-owned set (ac/jas/sotce/quiltnet), /// loaded from untracked config so client identities never live in tracked /// code — same convention as imsgConfig above. JSON shape: diff --git a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift index cb070606d9..9ce7da925d 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift @@ -22,6 +22,17 @@ enum TextSize { case far, near, tiny } +/// Deskflow KVM status for the menubar. All machine-specific identity +/// (role/label/agent) is read from the untracked `~/.config/slab/deskflow.json` +/// so tracked code stays generic — same convention as the iMessage bridge. +struct DeskflowState { + var configured: Bool = false // a deskflow.json with "enabled": true exists + var running: Bool = false // a deskflow-core process is alive + var role: String = "" // "server" | "client" (display only) + var label: String = "Deskflow" + var agent: String = "" // launchd label, e.g. computer.aesthetic.deskflow +} + struct StateSnapshot { var lidClosed: Bool = false var sleepDisabled: Bool = false @@ -44,6 +55,9 @@ struct StateSnapshot { /// whole status surface (polygon icon + themed terminals) then carries a /// shared "she texted" accent until the thread is read. var messageWaiting: Bool = false + /// Deskflow KVM — present only on machines with a deskflow.json; the + /// menu shows a status line + Start/Stop/Restart for the LaunchAgent. + var deskflow: DeskflowState = DeskflowState() /// True when slab-call-record is actively capturing a meeting WAV. /// Populated from ~/.ac-meeting-recording.json — the recorder script /// owns the state file; the menubar only reads it. @@ -57,6 +71,13 @@ struct StateSnapshot { var awaitingCount: Int { claudeSessions.filter { $0.state == .awaiting }.count } var anyAwaiting: Bool { awaitingCount > 0 } var anyActive: Bool { claudeSessions.contains(where: { $0.state != .stale }) } + /// Pure "resting" idle — nothing open and nothing pending. This is the + /// state the menubar shows as the slow, colour-flowing spinning line + /// (instead of a static glyph). messageWaiting is folded onto the + /// snapshot by AppDelegate after gather(), so it's authoritative here. + var idleResting: Bool { + claudeSessions.isEmpty && !messageWaiting && !ambientActive && !hasWork + } var statusLine: String { if anyAwaiting { return "\(awaitingCount) awaiting · \(totalActive) active" } @@ -89,6 +110,7 @@ struct StateSnapshot { s.themeByStatus = FileManager.default.fileExists(atPath: Paths.themeByStatusFlag) s.forceBright = FileManager.default.fileExists(atPath: Paths.forceBrightFlag) s.preferIterm = FileManager.default.fileExists(atPath: Paths.preferItermFlag) + s.deskflow = readDeskflow() s.tailnetPeers = TailnetPeer.query() s.claudeSessions = ClaudeSessionReader.active() s.popRenders = readPopRenders() @@ -98,6 +120,26 @@ struct StateSnapshot { return s } + /// Read Deskflow KVM status. Returns an unconfigured state on machines + /// without a `~/.config/slab/deskflow.json` (or with "enabled": false), + /// so the menu item only appears where Deskflow is actually wired. + /// "running" is a cheap `pgrep` for a live deskflow-core — runs off the + /// main tick inside gather(), alongside the existing ioreg/pmset forks. + private static func readDeskflow() -> DeskflowState { + var d = DeskflowState() + guard let data = FileManager.default.contents(atPath: Paths.deskflowConfig), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + (obj["enabled"] as? Bool) ?? false + else { return d } + d.configured = true + d.role = (obj["role"] as? String) ?? "" + d.label = (obj["label"] as? String) ?? "Deskflow" + d.agent = (obj["agent"] as? String) ?? "" + let pids = ShellRunner.output("/usr/bin/pgrep", args: ["-f", "deskflow-core"], timeout: 2) + d.running = !(pids?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + return d + } + /// Inspect ~/.ac-meeting-recording.json. The file exists only while /// slab-call-record has a live ffmpeg subprocess; we additionally /// verify the PID is alive (kill -0) so a stale file from a crashed