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>
This commit is contained in:
Waters Church
2026-09-11 10:27:08 -04:00
co-authored by Claude Opus 5
parent abc70aa53d
commit 1dbd55aa24
24 changed files with 2978 additions and 9 deletions
@@ -0,0 +1,95 @@
import AppKit
import Foundation
import ServiceManagement
import UserNotifications
/// Notifications about the server, for a booth machine nobody is watching.
/// Quiet by design: only a stop nobody asked for is worth interrupting anyone.
final class Notifier {
static let shared = Notifier()
private var authorized = false
private var asked = false
private var available: Bool { Bundle.main.bundleIdentifier != nil }
private init() {}
/// Ask once, when the operator turns the setting on.
func requestAuthorization(_ completion: ((Bool) -> Void)? = nil) {
guard available else { return completion?(false) ?? () }
asked = true
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
DispatchQueue.main.async {
self.authorized = granted
completion?(granted)
}
}
}
func refreshStatus(_ completion: @escaping (UNAuthorizationStatus) -> Void) {
guard available else { return completion(.denied) }
UNUserNotificationCenter.current().getNotificationSettings { settings in
DispatchQueue.main.async {
self.authorized = settings.authorizationStatus == .authorized
completion(settings.authorizationStatus)
}
}
}
/// Posting never asks for permission: a prompt that appears because
/// something just failed, unattended, is the worst possible moment for it.
/// Authorization is asked for on first run and when the setting is turned
/// on; until then this quietly does nothing.
func post(title: String, body: String, if enabled: Bool) {
guard enabled, available, authorized else { return }
let content = UNMutableNotificationContent()
content.title = title
content.body = body
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
}
/// "Open ProdDash at login", through the modern (macOS 13+) login-item API.
/// The operator may still have to approve it in System Settings — hence
/// `needsApproval`, which the window surfaces rather than silently failing.
enum LoginItem {
static var isEnabled: Bool { SMAppService.mainApp.status == .enabled }
static var needsApproval: Bool { SMAppService.mainApp.status == .requiresApproval }
@discardableResult
static func set(_ enabled: Bool) -> String? {
do {
if enabled {
if SMAppService.mainApp.status != .enabled { try SMAppService.mainApp.register() }
} else {
if SMAppService.mainApp.status == .enabled { try SMAppService.mainApp.unregister() }
}
return nil
} catch {
return error.localizedDescription
}
}
static func openSystemSettings() {
SMAppService.openSystemSettingsLoginItems()
}
}
enum SystemSettings {
/// Deep links into the Privacy & Security panes. Opening the app itself is
/// the fallback when a pane identifier stops working on a later macOS.
static func open(_ pane: String) {
guard let url = URL(string: pane) else { return }
if !NSWorkspace.shared.open(url) {
NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Settings.app"))
}
}
static let microphone = "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
static let localNetwork = "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork"
static let notifications = "x-apple.systempreferences:com.apple.Notifications-Settings.extension"
}