Files
Waters ChurchandClaude Opus 5 1dbd55aa24 macOS menu-bar launcher: run ProdDash, and own its permissions
A native Swift app (launcher/macos, ~2 MB, no dependencies) that starts,
stops and watches over the server from the menu bar — and holds the macOS
permissions its modules need.

The permissions are the point as much as the buttons are. macOS grants
privacy permissions to the application that owns a process, and a child
inherits its parent's identity, so with the server started from here node —
and the Timers module's ltc-capture — belong to this app bundle and the
microphone and local-network prompts are asked, and remembered, as ProdDash.
Started over SSH or from a launchd job there is nobody to ask: the permission
is silently absent rather than refused, which is the "endless zeroes" failure
ltc-listener.js warns about.

The shell was already built for a supervisor: with PRODDASH_LAUNCHER=1 it
exits 75 instead of respawning itself when the admin page applies an update,
which the launcher reads as a restart request rather than a fault. Crashes
get a backed-off restart (1s, 2s, 5s, 10s) and four in two minutes stops the
cycle. The server is spawned into its own process group, so stopping it also
stops anything it started — an orphaned ltc-capture would otherwise hold an
audio device indefinitely — and signals, a logout and Quit all stop it first.

- Menu bar: ProdDash's own 2x2 tile mark, its accent tile lit green while
  serving, amber mid-move, red when something needs a person. Start, stop,
  restart, open the dashboard or admin, open at login, quit.
- Window: status and both addresses, plus Log (also written to
  ~/Library/Logs/ProdDash, kept a week), Settings, and Permissions.
- The port is written to the data directory's proddash.json — ProdDash's own
  file — so the port set here holds however it is next started. Something
  already on the port is reported, and offered as a takeover when it answers
  like a ProdDash started another way.
- Modules declare what they need in their manifests ("permissions": [...],
  documented in docs/MODULE-GUIDE.md and ignored by the shell); the launcher
  lists exactly that, with each module's reason, asks for the microphone,
  and — since macOS reports nothing about local network access — offers a
  check that browses for services and tries every configured upstream.

Timers, ProdCom and ProPresenter modules declare their permissions (patch
bumps). Build with launcher/macos/build.sh; output is gitignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:27:08 -04:00

68 lines
2.7 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
/**
The menu-bar glyph: ProdDash's own icon — a 2×2 grid of tiles — with the
accent tile carrying the state, exactly as the app icon does. Filled and
green while it serves; hollow and dim when stopped; amber mid-move; red when
something needs a person.
Drawn rather than templated because a template image can only be one colour.
The outline uses `labelColor`, so it follows a light or dark menu bar; the
status item redraws it when the appearance changes.
*/
enum StatusIcon {
static let accent = NSColor(srgbRed: 0.18, green: 0.90, blue: 0.60, alpha: 1) // #2ee59a, the shell's --accent
static let warn = NSColor(srgbRed: 0.94, green: 0.71, blue: 0.16, alpha: 1) // #f0b429
static let danger = NSColor(srgbRed: 0.96, green: 0.26, blue: 0.24, alpha: 1) // #f4433c
static func color(for state: ServerState, healthy: Bool) -> NSColor {
switch state {
case .running: return healthy ? accent : warn
case .starting, .stopping: return warn
case .failed: return danger
case .stopped: return NSColor.secondaryLabelColor
}
}
static func image(for state: ServerState, healthy: Bool) -> NSImage {
let side: CGFloat = 18
let fill: NSColor? = {
switch state {
case .stopped: return nil // nothing lit: plainly off
default: return color(for: state, healthy: healthy)
}
}()
let image = NSImage(size: NSSize(width: side, height: side), flipped: false) { _ in
let inset: CGFloat = 1.5
let gap: CGFloat = 1.6
let tile = (side - inset * 2 - gap) / 2
let outline = NSColor.labelColor.withAlphaComponent(state == .stopped ? 0.55 : 0.9)
for row in 0..<2 {
for column in 0..<2 {
let rect = NSRect(x: inset + CGFloat(column) * (tile + gap),
y: inset + CGFloat(row) * (tile + gap),
width: tile, height: tile)
// Bottom-right is the accent tile, as on the app icon.
let isAccent = (row == 0 && column == 1)
let path = NSBezierPath(roundedRect: rect.insetBy(dx: 0.6, dy: 0.6), xRadius: 2, yRadius: 2)
if isAccent, let fill {
fill.setFill()
path.fill()
} else {
outline.setStroke()
path.lineWidth = 1.3
path.stroke()
}
}
}
return true
}
image.isTemplate = false
image.accessibilityDescription = "ProdDash — \(state.label)"
return image
}
}