From c6f82014323513aab94c6379c0b1c54ced22e45d Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 17 May 2026 17:15:09 -0400 Subject: [PATCH] slab: fullscreen status screensaver (.saver) Mirrors the menubar prompt-status polygon at fullscreen plus a system-resource readout (CPU/per-core, mem, load, uptime, clock). Shares the menubar's on-disk slab state, read-only. Sandbox-aware for Apple's legacyScreenSaver host: real home via getpwuid (not NSHomeDirectory), no subprocesses (Mach/sysctl/ getloadavg only), never reaps markers. Built with swiftc -emit-library -Xlinker -bundle so no Xcode project is needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- slab/screensaver/.gitignore | 1 + slab/screensaver/Info.plist | 30 ++ slab/screensaver/README.md | 47 +++ slab/screensaver/Sources/RealHome.swift | 20 + slab/screensaver/Sources/SlabSaverView.swift | 377 +++++++++++++++++++ slab/screensaver/Sources/SlabStatus.swift | 146 +++++++ slab/screensaver/Sources/SystemMetrics.swift | 126 +++++++ slab/screensaver/build.sh | 95 +++++ 8 files changed, 842 insertions(+) create mode 100644 slab/screensaver/.gitignore create mode 100644 slab/screensaver/Info.plist create mode 100644 slab/screensaver/README.md create mode 100644 slab/screensaver/Sources/RealHome.swift create mode 100644 slab/screensaver/Sources/SlabSaverView.swift create mode 100644 slab/screensaver/Sources/SlabStatus.swift create mode 100644 slab/screensaver/Sources/SystemMetrics.swift create mode 100755 slab/screensaver/build.sh diff --git a/slab/screensaver/.gitignore b/slab/screensaver/.gitignore new file mode 100644 index 0000000000..30bcfa4ed5 --- /dev/null +++ b/slab/screensaver/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/slab/screensaver/Info.plist b/slab/screensaver/Info.plist new file mode 100644 index 0000000000..675d0d11b3 --- /dev/null +++ b/slab/screensaver/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + Slab + CFBundleIdentifier + computer.slab.screensaver + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Slab + CFBundleDisplayName + Slab Status + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 13.0 + NSPrincipalClass + SlabSaverView + NSHumanReadableCopyright + aesthetic.computer · slab + + diff --git a/slab/screensaver/README.md b/slab/screensaver/README.md new file mode 100644 index 0000000000..801f35b949 --- /dev/null +++ b/slab/screensaver/README.md @@ -0,0 +1,47 @@ +# Slab Status screensaver + +A macOS `.saver` that shows, fullscreen, the abstract status of our prompts +plus this machine's system resources. Same visual language as the slab +menubar icon, scaled up. + +## What it shows + +- **Rotating polygon** — one edge per live Claude session, colored by that + session's state (green working / amber-pulse awaiting / slate complete / + gray blank / dim-blink stale). 1 session = a bar, 2 = parallel bars, + N≥3 = the matching N-gon. Idle = one slow-breathing ring. Rotation + speeds up the more sessions are awaiting you. +- **Status line + breakdown** — `4 awaiting · 5 active`, with non-zero + per-state chips and `ambient` / `muted` mode tags. +- **System resources** — CPU % with per-core ticks, memory used/total, + load average, uptime, clock. + +It reads the same on-disk state the menubar reads +(`~/.local/share/slab/state/{active,awaiting}-prompts`, mode flags) and is +strictly **read-only** and **subprocess-free** — both required to behave +inside Apple's sandboxed `legacyScreenSaver` host. + +## Build & install + +```bash +./build.sh # compile + install to ~/Library/Screen Savers +./build.sh --build # compile only (bundle lands in .build/) +``` + +No Xcode project — `swiftc` builds the loadable bundle directly +(`-emit-library -Xlinker -bundle`), so Command Line Tools is enough. +Ad-hoc signed, which is sufficient for a locally installed saver. + +After install, pick it in **System Settings → Screen Saver → Other → +"Slab Status"** (third-party savers live under the non-Apple group). + +## Notes / gotchas + +- Inside the screensaver sandbox, `NSHomeDirectory()` / `$HOME` point at + the saver's container, not `/Users/`. `RealHome` resolves the true + home via `getpwuid(getuid())` so the slab state path is correct. +- No `Process`/`NSTask`: lid/sleep/tailscale (which the menubar gets by + shelling out) are intentionally omitted. CPU/mem/load come from Mach / + `sysctl` / `getloadavg`, which are syscalls and work in the sandbox. +- `legacyScreenSaver` caches loaded bundles; `build.sh` kills it so the + next engage / settings-pane open picks up a fresh build. diff --git a/slab/screensaver/Sources/RealHome.swift b/slab/screensaver/Sources/RealHome.swift new file mode 100644 index 0000000000..09303d2a80 --- /dev/null +++ b/slab/screensaver/Sources/RealHome.swift @@ -0,0 +1,20 @@ +import Foundation +import Darwin + +/// Resolve the *real* user home directory. +/// +/// The screensaver runs inside Apple's sandboxed `legacyScreenSaver` host, +/// where `NSHomeDirectory()` / `$HOME` point at a per-saver container +/// (`~/Library/Containers/com.apple.ScreenSaver.Engine.legacyScreenSaver/…`) +/// rather than `/Users/`. The slab state lives under the real home, so +/// we read it from the passwd database (directory-services backed, immune to +/// the container redirection) instead of the environment. +enum RealHome { + static let path: String = { + if let pw = getpwuid(getuid()) { + let dir = String(cString: pw.pointee.pw_dir) + if !dir.isEmpty { return dir } + } + return ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory() + }() +} diff --git a/slab/screensaver/Sources/SlabSaverView.swift b/slab/screensaver/Sources/SlabSaverView.swift new file mode 100644 index 0000000000..f4c0639a6b --- /dev/null +++ b/slab/screensaver/Sources/SlabSaverView.swift @@ -0,0 +1,377 @@ +import AppKit +import ScreenSaver + +/// Fullscreen slab status screensaver. +/// +/// Centerpiece is the same motif as the menubar icon: a slowly rotating +/// regular polygon with one edge per live Claude session, each edge colored +/// by that session's state (green working / amber-pulse awaiting / slate +/// complete / gray blank / dim-blink stale). N=1 is a single bar, N=2 two +/// parallel bars, N≥3 the matching n-gon. Around it: an abstract numeric +/// breakdown of the prompts and a system-resource readout (CPU, memory, +/// load, uptime). No subprocesses, no disk writes — safe in the sandboxed +/// `legacyScreenSaver` host. +@objc(SlabSaverView) +final class SlabSaverView: ScreenSaverView { + + private let metrics = SystemMetrics() + private var status = SlabStatus() + private var sample = SystemMetrics.Sample() + private var phase: CGFloat = 0 // seconds, drives pulses + private var rotation: CGFloat = 0 // radians + private var frame_: Int = 0 + + private let clockFmt: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "HH:mm:ss"; return f + }() + private let dateFmt: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "EEE d MMM"; return f + }() + + private let bg = NSColor(deviceWhite: 0.035, alpha: 1) + + override init?(frame: NSRect, isPreview: Bool) { + super.init(frame: frame, isPreview: isPreview) + animationTimeInterval = 1.0 / 30.0 + reload() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + animationTimeInterval = 1.0 / 30.0 + reload() + } + + override var isOpaque: Bool { true } + override var hasConfigureSheet: Bool { false } + override var configureSheet: NSWindow? { nil } + + override func startAnimation() { super.startAnimation() } + override func stopAnimation() { super.stopAnimation() } + + private func reload() { + status = SlabStatus.gather() + sample = metrics.sample() + } + + override func animateOneFrame() { + let dt = CGFloat(animationTimeInterval) + phase += dt + + // Rotate gently while any thread is alive; quicker the more are + // waiting on you. Frozen (just breathing) when nothing's live. + let speed: CGFloat = status.anyLive + ? 0.06 + 0.06 * CGFloat(status.awaiting) + : 0 + rotation += speed * dt + + frame_ += 1 + if frame_ % 30 == 0 { reload() } // ~1s; also the CPU delta window + + setNeedsDisplay(bounds) + } + + // MARK: - Render + + override func draw(_ rect: NSRect) { + bg.setFill() + rect.fill() + + let w = bounds.width, h = bounds.height + let f = max(0.35, min(w / 1680.0, h / 1050.0)) // global scale + + drawHeader(w: w, h: h, f: f) + drawPolygon(w: w, h: h, f: f) + drawStatusBlock(w: w, h: h, f: f) + drawMetrics(w: w, h: h, f: f) + drawClock(w: w, h: h, f: f) + } + + // MARK: header (wordmark + host) + + private func drawHeader(w: CGFloat, h: CGFloat, f: CGFloat) { + let host = Host.current().localizedName ?? ProcessInfo.processInfo.hostName + text("slab", + at: NSPoint(x: w / 2, y: h - 64 * f), + size: 30 * f, weight: .bold, + color: NSColor(deviceWhite: 0.82, alpha: 1), center: true) + text(host, + at: NSPoint(x: w / 2, y: h - 92 * f), + size: 13 * f, weight: .regular, + color: NSColor(deviceWhite: 0.40, alpha: 1), center: true) + } + + // MARK: polygon (one edge per session) + + private let maxSides = 12 + + private func drawPolygon(w: CGFloat, h: CGFloat, f: CGFloat) { + let cx = w / 2, cy = h * 0.54 + let radius = min(w, h) * 0.21 + let lineWidth = max(2.5, radius * 0.035) + + let sessions = status.sessions + guard !sessions.isEmpty else { + // Idle: a single slow-breathing ring so the canvas isn't dead. + let breathe = 0.5 + 0.5 * cos(phase * 0.9) + let c = NSColor(deviceHue: 0.58, saturation: 0.22, + brightness: 0.32 + 0.20 * breathe, alpha: 1) + let ring = NSBezierPath(ovalIn: NSRect( + x: cx - radius, y: cy - radius, + width: radius * 2, height: radius * 2)) + ring.lineWidth = lineWidth + c.setStroke(); ring.stroke() + return + } + + let visible = Array(sessions.prefix(maxSides)) + let n = visible.count + let overflow = sessions.count - n + + if n == 1 { + segment(center: NSPoint(x: cx, y: cy), length: 2 * radius, + angle: rotation, color: color(visible[0].state), + width: lineWidth) + } else if n == 2 { + let half = radius * 0.36 + let length = radius * 1.8 + let nx = -sin(rotation), ny = cos(rotation) + for i in 0..<2 { + let s: CGFloat = i == 0 ? 1 : -1 + segment(center: NSPoint(x: cx + nx * half * s, y: cy + ny * half * s), + length: length, angle: rotation, + color: color(visible[i].state), width: lineWidth) + } + } else { + var verts: [NSPoint] = [] + for k in 0.. 0 { + text("+\(overflow)", + at: NSPoint(x: cx, y: cy), + size: radius * 0.22, weight: .semibold, + color: NSColor(deviceWhite: 0.7, alpha: 1), center: true) + } + } + + private func segment(center: NSPoint, length: CGFloat, angle: CGFloat, + color: NSColor, width: CGFloat) { + let dx = cos(angle) * length / 2, dy = sin(angle) * length / 2 + let p = NSBezierPath() + p.move(to: NSPoint(x: center.x - dx, y: center.y - dy)) + p.line(to: NSPoint(x: center.x + dx, y: center.y + dy)) + p.lineWidth = width + p.lineCapStyle = .round + color.setStroke(); p.stroke() + } + + /// Same palette as the menubar's `IconRenderer.sessionColor`. + private func color(_ st: SlabSessionState) -> NSColor { + switch st { + case .blank: + return NSColor(deviceWhite: 0.55, alpha: 1) + case .working: + return NSColor(deviceHue: 0.33, saturation: 0.70, brightness: 0.78, alpha: 1) + case .complete: + return NSColor(deviceHue: 0.58, saturation: 0.30, brightness: 0.70, alpha: 1) + case .awaiting: + let pulse = 0.5 + 0.5 * cos(phase * .pi * 4) + return NSColor(deviceHue: 0.10, saturation: 0.95, + brightness: 0.70 + 0.30 * pulse, alpha: 1) + case .stale: + let blink = 0.5 + 0.5 * cos(phase * .pi * 2) + return NSColor(deviceWhite: 0.28 + 0.32 * blink, alpha: 1) + } + } + + // MARK: status line + abstract breakdown + + private func drawStatusBlock(w: CGFloat, h: CGFloat, f: CGFloat) { + let cy = h * 0.54 + let radius = min(w, h) * 0.21 + let baseY = cy - radius - 56 * f + + let line = status.statusLine + let lineColor: NSColor = + status.anyAwaiting ? color(.awaiting) + : status.anyLive ? color(.working) + : NSColor(deviceWhite: 0.45, alpha: 1) + text(line, at: NSPoint(x: w / 2, y: baseY), + size: 40 * f, weight: .semibold, color: lineColor, center: true) + + // Abstract breakdown: colored count chips, only the non-zero ones. + var chips: [(String, NSColor)] = [] + if status.working > 0 { chips.append(("\(status.working) working", color(.working))) } + if status.awaiting > 0 { chips.append(("\(status.awaiting) awaiting", color(.awaiting))) } + if status.complete > 0 { chips.append(("\(status.complete) complete", color(.complete))) } + if status.blank > 0 { chips.append(("\(status.blank) blank", color(.blank))) } + if status.stale > 0 { chips.append(("\(status.stale) stale", color(.stale))) } + if status.activeSubagents > 0 { + chips.append(("+\(status.activeSubagents) subagents", + NSColor(deviceWhite: 0.55, alpha: 1))) + } + + if !chips.isEmpty { + let size = 16 * f, gap = 26 * f + let attrs = monoAttrs(size: size, weight: .medium, color: .white) + let widths = chips.map { + ($0.0 as NSString).size(withAttributes: attrs).width + } + let totalW = widths.reduce(0, +) + gap * CGFloat(chips.count - 1) + var x = w / 2 - totalW / 2 + let y = baseY - 34 * f + for (i, chip) in chips.enumerated() { + text(chip.0, at: NSPoint(x: x, y: y), size: size, + weight: .medium, color: chip.1, center: false) + x += widths[i] + gap + } + } + + // Mode tags (ambient / muted), faint, just under the breakdown. + var tags: [String] = [] + if status.ambientActive { tags.append("ambient") } + if status.muted { tags.append("muted") } + if !tags.isEmpty { + text(tags.joined(separator: " · "), + at: NSPoint(x: w / 2, y: baseY - 60 * f), + size: 13 * f, weight: .regular, + color: NSColor(deviceWhite: 0.38, alpha: 1), center: true) + } + } + + // MARK: system resources (bottom-left) + + private func drawMetrics(w: CGFloat, h: CGFloat, f: CGFloat) { + let x = 56 * f + let barW = max(220 * f, w * 0.20) + var y = 150 * f + let dim = NSColor(deviceWhite: 0.42, alpha: 1) + let val = NSColor(deviceWhite: 0.78, alpha: 1) + + // LOAD + let (l1, l5, l15) = sample.load + text("LOAD", at: NSPoint(x: x, y: y), size: 13 * f, + weight: .semibold, color: dim, center: false) + text(String(format: "%.2f %.2f %.2f", l1, l5, l15), + at: NSPoint(x: x + 64 * f, y: y), size: 14 * f, + weight: .regular, color: val, center: false) + y += 40 * f + + // MEM + let memTxt = "\(SystemMetrics.formatBytes(sample.memUsed)) / " + + "\(SystemMetrics.formatBytes(sample.memTotal)) GB" + labeledBar(label: "MEM", value: memTxt, + fraction: sample.memFraction, + color: NSColor(deviceHue: 0.58, saturation: 0.45, + brightness: 0.78, alpha: 1), + x: x, y: y, barW: barW, f: f) + y += 56 * f + + // CPU + per-core ticks + labeledBar(label: "CPU", + value: "\(Int(round(sample.cpuBusy * 100)))%", + fraction: sample.cpuBusy, + color: cpuColor(sample.cpuBusy), + x: x, y: y, barW: barW, f: f) + if !sample.perCore.isEmpty { + let cores = sample.perCore + let cw = max(3 * f, (barW - CGFloat(cores.count - 1) * 2 * f) + / CGFloat(cores.count)) + let maxH = 26 * f + let baseY = y + 26 * f + var cxp = x + for v in cores { + let bh = max(1.5 * f, CGFloat(v) * maxH) + NSColor(deviceWhite: 0.16, alpha: 1).setFill() + NSBezierPath(rect: NSRect(x: cxp, y: baseY, width: cw, height: maxH)).fill() + cpuColor(v).setFill() + NSBezierPath(rect: NSRect(x: cxp, y: baseY, width: cw, height: bh)).fill() + cxp += cw + 2 * f + } + } + } + + private func cpuColor(_ v: Double) -> NSColor { + // green → amber → red as load climbs. + let hue = 0.33 - 0.33 * CGFloat(min(1, max(0, v))) + return NSColor(deviceHue: hue, saturation: 0.75, brightness: 0.82, alpha: 1) + } + + private func labeledBar(label: String, value: String, fraction: Double, + color: NSColor, x: CGFloat, y: CGFloat, + barW: CGFloat, f: CGFloat) { + let dim = NSColor(deviceWhite: 0.42, alpha: 1) + let val = NSColor(deviceWhite: 0.78, alpha: 1) + text(label, at: NSPoint(x: x, y: y), size: 13 * f, + weight: .semibold, color: dim, center: false) + text(value, at: NSPoint(x: x + 64 * f, y: y), size: 14 * f, + weight: .regular, color: val, center: false) + let barY = y - 16 * f, barH = 8 * f + let track = NSBezierPath(roundedRect: + NSRect(x: x, y: barY, width: barW, height: barH), + xRadius: barH / 2, yRadius: barH / 2) + NSColor(deviceWhite: 0.15, alpha: 1).setFill(); track.fill() + let fw = max(barH, barW * CGFloat(min(1, max(0, fraction)))) + let fill = NSBezierPath(roundedRect: + NSRect(x: x, y: barY, width: fw, height: barH), + xRadius: barH / 2, yRadius: barH / 2) + color.setFill(); fill.fill() + } + + // MARK: clock + uptime (bottom-right) + + private func drawClock(w: CGFloat, h: CGFloat, f: CGFloat) { + let now = Date() + let rx = w - 56 * f + text(clockFmt.string(from: now), + at: NSPoint(x: rx, y: 150 * f), size: 34 * f, + weight: .semibold, color: NSColor(deviceWhite: 0.80, alpha: 1), + rightAlign: true) + text(dateFmt.string(from: now), + at: NSPoint(x: rx, y: 124 * f), size: 14 * f, + weight: .regular, color: NSColor(deviceWhite: 0.42, alpha: 1), + rightAlign: true) + text("up \(SystemMetrics.formatUptime(sample.uptime))", + at: NSPoint(x: rx, y: 100 * f), size: 13 * f, + weight: .regular, color: NSColor(deviceWhite: 0.34, alpha: 1), + rightAlign: true) + } + + // MARK: text helpers + + private func monoAttrs(size: CGFloat, weight: NSFont.Weight, + color: NSColor) -> [NSAttributedString.Key: Any] { + [.font: NSFont.monospacedSystemFont(ofSize: size, weight: weight), + .foregroundColor: color] + } + + /// Draw mono text. `center` horizontally-centers on `at`; `rightAlign` + /// right-justifies to `at.x`; otherwise `at` is the left baseline-ish + /// origin. `at.y` is treated as a vertical center. + private func text(_ s: String, at: NSPoint, size: CGFloat, + weight: NSFont.Weight, color: NSColor, + center: Bool = false, rightAlign: Bool = false) { + let attrs = monoAttrs(size: size, weight: weight, color: color) + let ns = s as NSString + let sz = ns.size(withAttributes: attrs) + var origin = NSPoint(x: at.x, y: at.y - sz.height / 2) + if center { origin.x = at.x - sz.width / 2 } + else if rightAlign { origin.x = at.x - sz.width } + ns.draw(at: origin, withAttributes: attrs) + } +} diff --git a/slab/screensaver/Sources/SlabStatus.swift b/slab/screensaver/Sources/SlabStatus.swift new file mode 100644 index 0000000000..428977db0f --- /dev/null +++ b/slab/screensaver/Sources/SlabStatus.swift @@ -0,0 +1,146 @@ +import Foundation +import Darwin + +/// Per-Claude-session state. Mirrors the menubar's `ClaudeSession.State` so +/// the screensaver paints the same colors as the menubar polygon icon. +/// +/// - `blank` SessionStart fired, no prompt yet — a fresh window. +/// - `working` Claude is actively running a turn. +/// - `complete` turn finished; alive but idle, no attention needed. +/// - `awaiting` paused mid-task, needs the user (permission / continue). +/// - `stale` claude_pid is gone; marker not yet reaped by the menubar. +enum SlabSessionState { + case blank, working, complete, awaiting, stale +} + +struct SlabSession { + let sessionId: String + let claudePid: Int + let updated: Date + var state: SlabSessionState +} + +/// Read-only snapshot of "the status of our prompts" plus a couple of slab +/// mode flags. This is a trimmed port of the menubar's `StateSnapshot` / +/// `ClaudeSessionReader`: file reads only, **no process spawning** (the +/// sandboxed screensaver host can't reliably fork) and **no mutation** (the +/// menubar owns reaping; the screensaver only observes). +struct SlabStatus { + var sessions: [SlabSession] = [] + var activePrompts: Int = 0 + var activeSubagents: Int = 0 + var ambientActive: Bool = false + var muted: Bool = false + var stateDirReadable: Bool = false + + var working: Int { sessions.filter { $0.state == .working }.count } + var awaiting: Int { sessions.filter { $0.state == .awaiting }.count } + var complete: Int { sessions.filter { $0.state == .complete }.count } + var blank: Int { sessions.filter { $0.state == .blank }.count } + var stale: Int { sessions.filter { $0.state == .stale }.count } + var anyAwaiting: Bool { awaiting > 0 } + var anyLive: Bool { sessions.contains { $0.state != .stale } } + + /// One-line summary, matching the spirit of the menubar's `statusLine`. + var statusLine: String { + if !stateDirReadable { return "slab" } + if anyAwaiting { return "\(awaiting) awaiting · \(sessions.count) active" } + if ambientActive && !sessions.isEmpty { return "ambient — \(sessions.count) active" } + if ambientActive { return "ambient" } + if sessions.isEmpty { return "idle" } + return "\(sessions.count) active" + } + + private static var stateDir: String { "\(RealHome.path)/.local/share/slab/state" } + + static func gather() -> SlabStatus { + var s = SlabStatus() + let fm = FileManager.default + let activeDir = "\(stateDir)/active-prompts" + let awaitingDir = "\(stateDir)/awaiting-prompts" + + s.ambientActive = fm.fileExists(atPath: "/tmp/slab-ambient-active") + s.muted = fm.fileExists(atPath: "\(stateDir)/muted") + s.activeSubagents = countFiles("\(stateDir)/active-subagents") + + guard let names = try? fm.contentsOfDirectory(atPath: activeDir) else { + // Either no work, or the sandbox blocked the read. Either way we + // degrade gracefully to a clean idle render. + return s + } + s.stateDirReadable = true + + var awaitingMsgs: [String: String] = [:] + if let an = try? fm.contentsOfDirectory(atPath: awaitingDir) { + for name in an where !name.hasPrefix(".") { + let msg = (try? String(contentsOfFile: "\(awaitingDir)/\(name)", encoding: .utf8))? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + awaitingMsgs[name] = msg.isEmpty ? "awaiting input" : msg + } + } + + for name in names where !name.hasPrefix(".") { + guard let sess = parse(path: "\(activeDir)/\(name)", fallbackId: name) else { continue } + var v = sess + if v.claudePid > 0 && !pidAlive(v.claudePid) { + v.state = .stale // observe only — never unlink + } else if let msg = awaitingMsgs[v.sessionId] { + v.state = msg.lowercased().hasPrefix("turn complete") ? .complete : .awaiting + } else if v.state != .blank { + v.state = .working + } + s.sessions.append(v) + } + s.activePrompts = s.sessions.count + + // Attention first, then calm, then fresh, then dead — same banding + // as the menubar so the polygon edges read consistently. + s.sessions.sort { a, b in + func rank(_ st: SlabSessionState) -> Int { + switch st { + case .awaiting: return 0 + case .complete: return 1 + case .working: return 2 + case .blank: return 3 + case .stale: return 4 + } + } + let ra = rank(a.state), rb = rank(b.state) + return ra != rb ? ra < rb : a.updated > b.updated + } + return s + } + + private static func countFiles(_ dir: String) -> Int { + guard let c = try? FileManager.default.contentsOfDirectory(atPath: dir) else { return 0 } + return c.filter { !$0.hasPrefix(".") }.count + } + + private static func parse(path: String, fallbackId: String) -> SlabSession? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil } + let mtime = (try? FileManager.default.attributesOfItem(atPath: path)[.modificationDate] as? Date) ?? Date() + if data.isEmpty { + return SlabSession(sessionId: fallbackId, claudePid: 0, updated: mtime, state: .working) + } + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + let iso = ISO8601DateFormatter() + let updated: Date = { + if let str = obj["updated"] as? String, let d = iso.date(from: str) { return d } + return mtime + }() + let state: SlabSessionState = ((obj["state"] as? String) == "blank") ? .blank : .working + return SlabSession( + sessionId: (obj["session_id"] as? String) ?? fallbackId, + claudePid: (obj["claude_pid"] as? Int) ?? 0, + updated: updated, + state: state + ) + } + + /// `kill(pid, 0)` is a bare syscall (no fork), so it's safe inside the + /// screensaver sandbox. EPERM means alive-but-not-ours. + private static func pidAlive(_ pid: Int) -> Bool { + guard pid > 0 else { return false } + return kill(pid_t(pid), 0) == 0 || errno == EPERM + } +} diff --git a/slab/screensaver/Sources/SystemMetrics.swift b/slab/screensaver/Sources/SystemMetrics.swift new file mode 100644 index 0000000000..bbcf9204c5 --- /dev/null +++ b/slab/screensaver/Sources/SystemMetrics.swift @@ -0,0 +1,126 @@ +import Foundation +import Darwin + +/// In-process system-resource sampler. Everything here is a Mach / sysctl +/// call — no subprocesses — so it works inside the sandboxed screensaver +/// host. Keep one instance alive across frames: CPU % is a delta between +/// successive `host_processor_info` reads. +final class SystemMetrics { + struct Sample { + var cpuBusy: Double = 0 // 0…1 aggregate + var perCore: [Double] = [] // 0…1 per logical core + var memUsed: UInt64 = 0 // bytes (active + wired + compressed) + var memTotal: UInt64 = 0 // bytes + var load: (Double, Double, Double) = (0, 0, 0) + var uptime: TimeInterval = 0 // seconds since boot + + var memFraction: Double { + memTotal == 0 ? 0 : min(1, Double(memUsed) / Double(memTotal)) + } + } + + private var prevTicks: [(user: UInt32, sys: UInt32, idle: UInt32, nice: UInt32)] = [] + private let memTotal: UInt64 = { + var size: UInt64 = 0 + var len = MemoryLayout.size + sysctlbyname("hw.memsize", &size, &len, nil, 0) + return size + }() + + func sample() -> Sample { + var s = Sample() + s.memTotal = memTotal + s.uptime = ProcessInfo.processInfo.systemUptime + + var loads = [Double](repeating: 0, count: 3) + if getloadavg(&loads, 3) == 3 { + s.load = (loads[0], loads[1], loads[2]) + } + + sampleMemory(into: &s) + sampleCPU(into: &s) + return s + } + + private func sampleMemory(into s: inout Sample) { + var stats = vm_statistics64() + var count = mach_msg_type_number_t( + MemoryLayout.stride / MemoryLayout.stride) + let kr = withUnsafeMutablePointer(to: &stats) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count) + } + } + guard kr == KERN_SUCCESS else { return } + let page = UInt64(vm_kernel_page_size) + let used = UInt64(stats.active_count) + + UInt64(stats.wire_count) + + UInt64(stats.compressor_page_count) + s.memUsed = used * page + } + + private func sampleCPU(into s: inout Sample) { + var cpuInfo: processor_info_array_t? + var numInfo: mach_msg_type_number_t = 0 + var numCPUs: natural_t = 0 + let kr = host_processor_info( + mach_host_self(), PROCESSOR_CPU_LOAD_INFO, &numCPUs, &cpuInfo, &numInfo) + guard kr == KERN_SUCCESS, let info = cpuInfo else { return } + defer { + vm_deallocate(mach_task_self_, + vm_address_t(bitPattern: info), + vm_size_t(numInfo) * vm_size_t(MemoryLayout.stride)) + } + + let n = Int(numCPUs) + var cur: [(UInt32, UInt32, UInt32, UInt32)] = [] + cur.reserveCapacity(n) + for i in 0.. 0 ? min(1, busy / total) : 0) + busyAcc += busy + totalAcc += total + } + prevTicks = cur.map { (user: $0.0, sys: $0.1, idle: $0.2, nice: $0.3) } + s.perCore = per + s.cpuBusy = totalAcc > 0 ? min(1, busyAcc / totalAcc) : 0 + } + + static func formatBytes(_ bytes: UInt64) -> String { + let gb = Double(bytes) / 1_073_741_824.0 + return String(format: "%.1f", gb) + } + + static func formatUptime(_ secs: TimeInterval) -> String { + let t = Int(secs) + let d = t / 86400, h = (t % 86400) / 3600, m = (t % 3600) / 60 + if d > 0 { return "\(d)d \(h)h" } + if h > 0 { return "\(h)h \(m)m" } + return "\(m)m" + } +} diff --git a/slab/screensaver/build.sh b/slab/screensaver/build.sh new file mode 100755 index 0000000000..4d1dadd445 --- /dev/null +++ b/slab/screensaver/build.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# build.sh — compile and install the Slab status screensaver. +# +# Produces a loadable `.saver` bundle with swiftc directly (no Xcode +# project needed — Command Line Tools is enough) and drops it into +# ~/Library/Screen Savers/. Idempotent; safe to re-run after edits. +# +# ./build.sh build + install +# ./build.sh --build build only (assemble bundle, don't install) + +set -euo pipefail + +CYAN=$'\033[1;36m'; GREEN=$'\033[1;32m'; YELLOW=$'\033[1;33m'; RESET=$'\033[0m' +say() { printf "%s• %s%s\n" "$CYAN" "$1" "$RESET"; } +ok() { printf "%s✓ %s%s\n" "$GREEN" "$1" "$RESET"; } +warn(){ printf "%s! %s%s\n" "$YELLOW" "$1" "$RESET"; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +command -v swiftc >/dev/null 2>&1 || { + echo "swiftc not found — install Command Line Tools: xcode-select --install" + exit 1 +} + +SDK="$(xcrun --sdk macosx --show-sdk-path)" +ARCH="$(uname -m)" +TARGET="${ARCH}-apple-macos13.0" +BUILD="$SCRIPT_DIR/.build" +SAVER="$BUILD/Slab.saver" +MACOS_DIR="$SAVER/Contents/MacOS" +RES_DIR="$SAVER/Contents/Resources" + +say "compiling Slab.saver ($ARCH, swift $(swiftc --version | head -1 | awk '{print $4}'))" +rm -rf "$SAVER" +mkdir -p "$MACOS_DIR" "$RES_DIR" + +# `-emit-library` puts swiftc in library-link mode (no implicit main); +# `-Xlinker -bundle` flips the Mach-O type from dylib to MH_BUNDLE, which +# is what NSBundle's principalClass loader expects from a `.saver`. +# `-swift-version 5` relaxes Swift-6 strict concurrency for the AppKit +# overrides. +swiftc \ + -emit-library \ + -o "$MACOS_DIR/Slab" \ + -module-name Slab \ + -swift-version 5 \ + -target "$TARGET" \ + -sdk "$SDK" \ + -O \ + -framework ScreenSaver \ + -framework AppKit \ + -framework Foundation \ + -Xlinker -bundle \ + Sources/*.swift + +if ! file "$MACOS_DIR/Slab" | grep -q "bundle"; then + warn "expected a Mach-O bundle; got: $(file "$MACOS_DIR/Slab")" +fi + +cp "$SCRIPT_DIR/Info.plist" "$SAVER/Contents/Info.plist" +ok "bundle assembled: $SAVER" + +# Ad-hoc signing — sufficient for a locally built saver in ~/Library. +codesign --force --deep --sign - \ + --identifier computer.slab.screensaver \ + "$SAVER" >/dev/null 2>&1 \ + && ok "ad-hoc signed" \ + || warn "codesign failed (saver may still load locally)" + +if [[ "${1:-}" == "--build" ]]; then + printf "\n%sbuilt only.%s bundle: %s\n" "$GREEN" "$RESET" "$SAVER" + exit 0 +fi + +DEST_DIR="$HOME/Library/Screen Savers" +DEST="$DEST_DIR/Slab.saver" +say "installing → $DEST" +mkdir -p "$DEST_DIR" + +# legacyScreenSaver caches loaded bundles; killing it forces a fresh load +# next time the screensaver engages or the System Settings pane opens. +rm -rf "$DEST" +cp -R "$SAVER" "$DEST" +ok "installed" + +pkill -x legacyScreenSaver 2>/dev/null || true +killall ScreenSaverEngine 2>/dev/null || true + +printf "\n%sdone.%s\n" "$GREEN" "$RESET" +echo " bundle: $DEST" +echo +echo " Select it: System Settings → Screen Saver → Other → \"Slab Status\"" +echo " (it appears under the 'Other' / non-Apple group)" +echo " Preview now: open the Screen Saver settings pane and pick it." -- 2.51.2