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>
235 lines
8.3 KiB
Swift
235 lines
8.3 KiB
Swift
import AVFoundation
|
|
import SwiftUI
|
|
import UserNotifications
|
|
|
|
/**
|
|
The permissions pane.
|
|
|
|
This is the reason the launcher exists as much as the start button is: macOS
|
|
grants privacy permissions to the *app* that owns the process tree, so when
|
|
ProdDash runs as this app's child, the prompts are asked in ProdDash's name
|
|
and stay granted. What each module needs is read from its own manifest, so
|
|
this list matches what is actually installed.
|
|
*/
|
|
struct PermissionsPane: View {
|
|
|
|
@ObservedObject var server: ServerController
|
|
@ObservedObject var settings: LauncherSettings
|
|
@ObservedObject var permissions: PermissionsModel
|
|
|
|
var body: some View {
|
|
Form {
|
|
Section {
|
|
Text("ProdDash's modules ask macOS for these through the launcher. Start the server any other way "
|
|
+ "— over SSH, from a launchd job — and the permissions belong to that instead, which is why "
|
|
+ "LTC goes quietly silent when it isn't launched from here.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
if permissions.needs.isEmpty {
|
|
Section("Modules") {
|
|
Text("No installed module declares a permission.")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
ForEach(permissions.needs) { need in
|
|
Section(need.kind.title) {
|
|
NeedHeader(need: need)
|
|
switch need.kind {
|
|
case .microphone: microphoneControls
|
|
case .localNetwork: localNetworkControls
|
|
case .other:
|
|
Text("The launcher doesn't manage this one — grant it in System Settings → Privacy & Security.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
Section("Notifications") {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text("Tell me when ProdDash stops")
|
|
Text("The launcher's own — so a server that stops on a booth machine nobody is watching "
|
|
+ "still reaches someone.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
StatusText(text: notificationLabel, tone: notificationTone)
|
|
}
|
|
Toggle("Notify me", isOn: Binding(
|
|
get: { settings.notifyOnProblem },
|
|
set: { on in
|
|
settings.notifyOnProblem = on
|
|
if on { permissions.requestNotifications() }
|
|
}))
|
|
if permissions.notifications == .denied {
|
|
Button("Open Notification Settings") { SystemSettings.open(SystemSettings.notifications) }
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
|
|
Section {
|
|
Button("Re-check") {
|
|
permissions.refresh(root: server.rootURL, dataDir: server.dataDir)
|
|
}
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
.onAppear { permissions.refresh(root: server.rootURL, dataDir: server.dataDir) }
|
|
}
|
|
|
|
// MARK: - Microphone
|
|
|
|
private var microphoneControls: some View {
|
|
HStack {
|
|
StatusText(text: permissions.microphone.description, tone: microphoneTone)
|
|
Spacer()
|
|
switch permissions.microphone {
|
|
case .notDetermined:
|
|
Button("Allow…") { permissions.requestMicrophone() }
|
|
case .denied, .restricted:
|
|
Button("Open System Settings") { SystemSettings.open(SystemSettings.microphone) }
|
|
default:
|
|
Button("Open System Settings") { SystemSettings.open(SystemSettings.microphone) }
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var microphoneTone: Tone {
|
|
switch permissions.microphone {
|
|
case .authorized: return .good
|
|
case .denied, .restricted: return .bad
|
|
default: return .unknown
|
|
}
|
|
}
|
|
|
|
// MARK: - Local network
|
|
|
|
private var localNetworkControls: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("macOS never reports whether this was granted, so the check below asks the network directly: "
|
|
+ "it browses for services (which is what raises the prompt) and tries every upstream the admin "
|
|
+ "page is pointed at.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
HStack(spacing: 8) {
|
|
Button(permissions.checking ? "Checking…" : "Check Access") {
|
|
permissions.checkLocalNetwork(root: server.rootURL, dataDir: server.dataDir)
|
|
}
|
|
.disabled(permissions.checking)
|
|
if permissions.checking {
|
|
ProgressView().controlSize(.small)
|
|
}
|
|
Spacer()
|
|
Button("Open System Settings") { SystemSettings.open(SystemSettings.localNetwork) }
|
|
.controlSize(.small)
|
|
}
|
|
|
|
ForEach(permissions.hostChecks) { check in
|
|
HStack(spacing: 8) {
|
|
Circle()
|
|
.fill(tone(for: check.result).color)
|
|
.frame(width: 7, height: 7)
|
|
Text(check.label)
|
|
.font(.system(.caption, design: .monospaced))
|
|
Text(check.module)
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
Spacer()
|
|
Text(describe(check.result))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
if !permissions.localNetworkVerdict.isEmpty {
|
|
Text(permissions.localNetworkVerdict)
|
|
.font(.caption)
|
|
.foregroundStyle(permissions.localNetworkVerdict.contains("working") ? .secondary : .primary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func describe(_ result: Net.Probe?) -> String {
|
|
switch result {
|
|
case .none: return "—"
|
|
case .open: return "answered"
|
|
case .refused: return "reachable, nothing on that port"
|
|
case .unreachable: return "no route"
|
|
case .timedOut: return "no answer"
|
|
case .failed(let why): return why
|
|
}
|
|
}
|
|
|
|
private func tone(for result: Net.Probe?) -> Tone {
|
|
guard let result else { return .unknown }
|
|
return result.isReachableHost ? .good : .bad
|
|
}
|
|
|
|
private var notificationLabel: String {
|
|
switch permissions.notifications {
|
|
case .authorized, .provisional, .ephemeral: return "granted"
|
|
case .denied: return "denied"
|
|
case .notDetermined: return "not asked yet"
|
|
@unknown default: return "unknown"
|
|
}
|
|
}
|
|
|
|
private var notificationTone: Tone {
|
|
switch permissions.notifications {
|
|
case .authorized, .provisional, .ephemeral: return .good
|
|
case .denied: return .bad
|
|
default: return .unknown
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Small pieces
|
|
|
|
enum Tone {
|
|
case good, bad, unknown
|
|
|
|
var color: Color {
|
|
switch self {
|
|
case .good: return Color(nsColor: StatusIcon.accent)
|
|
case .bad: return Color(nsColor: StatusIcon.danger)
|
|
case .unknown: return .secondary
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct StatusText: View {
|
|
let text: String
|
|
let tone: Tone
|
|
|
|
var body: some View {
|
|
HStack(spacing: 6) {
|
|
Circle().fill(tone.color).frame(width: 7, height: 7)
|
|
Text(text).font(.callout)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct NeedHeader: View {
|
|
let need: ModuleNeed
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
ForEach(need.claims) { claim in
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(claim.reason)
|
|
Text("Needed by \(claim.askedBy)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|