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:
co-authored by
Claude Opus 5
parent
abc70aa53d
commit
1dbd55aa24
@@ -0,0 +1,395 @@
|
||||
import AVFoundation
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
|
||||
/**
|
||||
The launcher window: what ProdDash is doing at the top, and the three things
|
||||
an operator ever needs underneath — what it is saying (Log), how it runs
|
||||
(Settings), and what macOS is letting it do (Permissions).
|
||||
*/
|
||||
struct LauncherView: View {
|
||||
|
||||
@ObservedObject var server: ServerController
|
||||
@ObservedObject var settings: LauncherSettings
|
||||
@ObservedObject var permissions: PermissionsModel
|
||||
let onHide: () -> Void
|
||||
let onQuit: () -> Void
|
||||
|
||||
private enum Tab: String, CaseIterable, Identifiable {
|
||||
case log = "Log", settings = "Settings", permissions = "Permissions"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
@State private var tab: Tab = .log
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
addresses
|
||||
Divider()
|
||||
Picker("", selection: $tab) {
|
||||
ForEach(Tab.allCases) { Text($0.rawValue).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
|
||||
Group {
|
||||
switch tab {
|
||||
case .log:
|
||||
LogPane()
|
||||
case .settings:
|
||||
SettingsPane(server: server, settings: settings)
|
||||
case .permissions:
|
||||
PermissionsPane(server: server, settings: settings, permissions: permissions)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.frame(minWidth: 540, idealWidth: 580, maxWidth: .infinity,
|
||||
minHeight: 520, idealHeight: 740, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(nsImage: NSApp.applicationIconImage)
|
||||
.resizable()
|
||||
.frame(width: 46, height: 46)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("ProdDash").font(.system(size: 19, weight: .semibold))
|
||||
Text(subtitle).font(.callout).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 5) {
|
||||
StatusPill(state: server.state, healthy: server.healthy)
|
||||
if !server.message.isEmpty {
|
||||
Text(server.message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(maxWidth: 260, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 22)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
var parts: [String] = []
|
||||
if !server.shellVersion.isEmpty { parts.append("v\(server.shellVersion)") }
|
||||
parts.append("port \(server.port)")
|
||||
if !server.nodeVersion.isEmpty { parts.append("node \(server.nodeVersion)") }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
// MARK: - Addresses and the controls that matter
|
||||
|
||||
private var addresses: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if server.state == .running {
|
||||
AddressRow(label: "This Mac", url: server.localURL.isEmpty
|
||||
? "http://localhost:\(server.port)" : server.localURL)
|
||||
ForEach(server.networkURLs, id: \.self) { url in
|
||||
AddressRow(label: "Network", url: url)
|
||||
}
|
||||
if !server.modulesSummary.isEmpty {
|
||||
Text("Modules: \(server.modulesSummary)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
} else {
|
||||
Text("Not serving. ProdDash will come up on port " + String(server.port) + ".")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
switch server.state {
|
||||
case .running, .starting:
|
||||
Button("Stop") { server.stop() }
|
||||
case .stopping:
|
||||
Button("Stopping…") {}.disabled(true)
|
||||
case .stopped, .failed:
|
||||
Button("Start") { server.start() }.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
Button("Restart") { server.restart() }
|
||||
.disabled(server.state != .running && server.state != .starting)
|
||||
Spacer()
|
||||
Button("Open Dashboard") { server.openDashboard() }
|
||||
.disabled(server.state != .running)
|
||||
Button("Admin") { server.openAdmin() }
|
||||
.disabled(server.state != .running)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
|
||||
// MARK: - Footer
|
||||
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
Button("Hide") { onHide() }
|
||||
.help("Closes this window; ProdDash keeps running in the menu bar.")
|
||||
Spacer()
|
||||
Text("Launcher \(AppDelegate.version)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
Spacer()
|
||||
Button("Quit ProdDash") { onQuit() }
|
||||
.help("Stops the server and quits the launcher.")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pieces
|
||||
|
||||
private struct StatusPill: View {
|
||||
let state: ServerState
|
||||
let healthy: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 7) {
|
||||
Circle()
|
||||
.fill(Color(nsColor: StatusIcon.color(for: state, healthy: healthy)))
|
||||
.frame(width: 9, height: 9)
|
||||
Text(state == .running && !healthy ? "Running (not answering)" : state.label)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
}
|
||||
.padding(.horizontal, 11)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color(nsColor: .quaternaryLabelColor).opacity(0.5), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
private struct AddressRow: View {
|
||||
let label: String
|
||||
let url: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 58, alignment: .leading)
|
||||
Text(url)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(url, forType: .string)
|
||||
} label: {
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Copy this address")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Log
|
||||
|
||||
private struct LogPane: View {
|
||||
@ObservedObject private var log = LogStore.shared
|
||||
@State private var follow = true
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(log.lines) { line in
|
||||
Text(line.text)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(color(for: line.kind))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.id(line.id)
|
||||
}
|
||||
Color.clear.frame(height: 1).id("bottom")
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
.background(Color(nsColor: .textBackgroundColor))
|
||||
.onChange(of: log.lines.count) { _ in
|
||||
guard follow else { return }
|
||||
withAnimation(.none) { proxy.scrollTo("bottom", anchor: .bottom) }
|
||||
}
|
||||
.onAppear { proxy.scrollTo("bottom", anchor: .bottom) }
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Toggle("Follow", isOn: $follow).toggleStyle(.checkbox)
|
||||
Spacer()
|
||||
Button("Copy") {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(log.plainText, forType: .string)
|
||||
}
|
||||
Button("Reveal Log File") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([log.currentFile])
|
||||
}
|
||||
Button("Clear") { log.clear() }
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
private func color(for kind: LogStore.Kind) -> Color {
|
||||
switch kind {
|
||||
case .launcher: return .accentColor
|
||||
case .err: return .orange
|
||||
case .out: return .primary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
private struct SettingsPane: View {
|
||||
@ObservedObject var server: ServerController
|
||||
@ObservedObject var settings: LauncherSettings
|
||||
|
||||
@State private var portText = ""
|
||||
@State private var loginItemOn = LoginItem.isEnabled
|
||||
@State private var loginNeedsApproval = LoginItem.needsApproval
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("ProdDash") {
|
||||
HStack {
|
||||
TextField("Port", text: $portText)
|
||||
.frame(width: 90)
|
||||
.onSubmit(applyPort)
|
||||
Button("Apply", action: applyPort)
|
||||
.disabled(Int(portText) == server.port || Int(portText) == nil)
|
||||
Spacer()
|
||||
}
|
||||
Text("Saved to ProdDash's own settings, so it keeps this port however it is started. "
|
||||
+ "Changing it while the server runs restarts it.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Starting up") {
|
||||
Toggle("Open ProdDash at login", isOn: Binding(
|
||||
get: { loginItemOn },
|
||||
set: { setLoginItem($0) }))
|
||||
if loginNeedsApproval {
|
||||
HStack(spacing: 8) {
|
||||
Text("macOS is holding this until you allow it.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Button("Open Login Items") { LoginItem.openSystemSettings() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
Toggle("Start the server when the launcher opens", isOn: $settings.startServerAtLaunch)
|
||||
Toggle("Restart the server if it stops unexpectedly", isOn: $settings.restartOnCrash)
|
||||
Toggle("Open the dashboard in a browser when the server starts", isOn: $settings.openDashboardOnStart)
|
||||
Toggle("Show this window at launch", isOn: $settings.showWindowAtLaunch)
|
||||
}
|
||||
|
||||
Section("Where things are") {
|
||||
PathRow(title: "ProdDash folder",
|
||||
value: server.rootURL?.path ?? "Not found — choose it",
|
||||
missing: server.rootURL == nil) {
|
||||
chooseFolder()
|
||||
}
|
||||
PathRow(title: "Node.js",
|
||||
value: server.nodeURL.map { "\($0.path) \(server.nodeVersion)" } ?? "Not found",
|
||||
missing: server.nodeURL == nil) {
|
||||
chooseNode()
|
||||
}
|
||||
PathRow(title: "Settings", value: server.settingsDir.isEmpty
|
||||
? server.dataDir.path : server.settingsDir, missing: false, action: nil)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.onAppear { portText = String(server.port) }
|
||||
.onChange(of: server.port) { newValue in portText = String(newValue) }
|
||||
}
|
||||
|
||||
private func applyPort() {
|
||||
guard let port = Int(portText) else {
|
||||
portText = String(server.port)
|
||||
return
|
||||
}
|
||||
server.applyPort(port)
|
||||
portText = String(server.port)
|
||||
}
|
||||
|
||||
private func setLoginItem(_ on: Bool) {
|
||||
if let error = LoginItem.set(on) {
|
||||
LogStore.shared.launcher("couldn't change the login item: \(error)")
|
||||
}
|
||||
loginItemOn = LoginItem.isEnabled
|
||||
loginNeedsApproval = LoginItem.needsApproval
|
||||
}
|
||||
|
||||
private func chooseFolder() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
panel.message = "Choose the ProdDash folder — the one with server.js in it."
|
||||
panel.prompt = "Choose"
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
guard ProdDash.isRoot(url) else {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "That isn't a ProdDash folder"
|
||||
alert.informativeText = "Pick the folder that contains server.js and public/."
|
||||
alert.runModal()
|
||||
return
|
||||
}
|
||||
settings.rootPath = url.path
|
||||
server.refreshEnvironment()
|
||||
}
|
||||
|
||||
private func chooseNode() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.message = "Choose the node binary ProdDash should run on."
|
||||
panel.prompt = "Choose"
|
||||
panel.directoryURL = URL(fileURLWithPath: "/usr/local/bin")
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
settings.nodePath = url.path
|
||||
server.refreshEnvironment()
|
||||
}
|
||||
}
|
||||
|
||||
private struct PathRow: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let missing: Bool
|
||||
var action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
Text(value)
|
||||
.font(.caption)
|
||||
.foregroundStyle(missing ? Color.orange : Color.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
Spacer()
|
||||
if let action {
|
||||
Button("Choose…", action: action).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user