Launcher: strip the window back to what's worth reading

A pass for less prose and fewer boxes.

- Header shows the version and node; the port is already in the addresses
  below it, and the module list belonged in the log.
- Settings: "Network" over the port (no explanation under it), "Startup"
  instead of "Starting up", and no node chooser — the path stays visible,
  since which node runs is the first thing worth knowing when it won't.
- Permissions: one line per permission — a status dot, its name, and the one
  thing you can do about it. The standing explanation is gone and the
  modules' own reasons moved to each row's tooltip. Local network results
  still appear under their row after a check, with a short line and a link
  only when nothing answered at all. Re-check sits centred at the bottom,
  outside the group.

The app-level "notify me" switch goes with it: macOS's own per-app
notification setting already does that job, so notifications now follow the
permission alone, asked for by the row's Enable button rather than on first
run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-11 11:10:22 -04:00
co-authored by Claude Opus 5
parent 1dbd55aa24
commit e1bceb3e60
9 changed files with 188 additions and 227 deletions
-3
View File
@@ -299,9 +299,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
/// Ask for what the installed modules actually declare they need.
func requestDeclaredPermissions() {
permissions.refresh(root: server.rootURL, dataDir: server.dataDir)
// Notifications are the launcher's own ask, and they are on by default,
// so get the prompt over with now rather than at the first fault.
if settings.notifyOnProblem { permissions.requestNotifications() }
for need in permissions.needs {
switch need.kind {
case .microphone:
@@ -15,9 +15,6 @@ final class LauncherSettings: ObservableObject {
/// Bring the server back if it dies on its own. Backs off, and gives up
/// after a burst of fast crashes rather than looping forever.
@Published var restartOnCrash: Bool { didSet { defaults.set(restartOnCrash, forKey: "restartOnCrash") } }
/// Notify when the server stops unexpectedly — the point of a booth machine
/// nobody is watching.
@Published var notifyOnProblem: Bool { didSet { defaults.set(notifyOnProblem, forKey: "notifyOnProblem") } }
/// Open the dashboard in a browser whenever the server comes up.
@Published var openDashboardOnStart: Bool { didSet { defaults.set(openDashboardOnStart, forKey: "openDashboardOnStart") } }
/// Show this window at launch. Off means the launcher starts straight into
@@ -28,7 +25,6 @@ final class LauncherSettings: ObservableObject {
defaults.register(defaults: [
"startServerAtLaunch": true,
"restartOnCrash": true,
"notifyOnProblem": true,
"openDashboardOnStart": false,
"showWindowAtLaunch": true,
])
@@ -36,7 +32,6 @@ final class LauncherSettings: ObservableObject {
nodePath = defaults.string(forKey: "nodePath") ?? ""
startServerAtLaunch = defaults.bool(forKey: "startServerAtLaunch")
restartOnCrash = defaults.bool(forKey: "restartOnCrash")
notifyOnProblem = defaults.bool(forKey: "notifyOnProblem")
openDashboardOnStart = defaults.bool(forKey: "openDashboardOnStart")
showWindowAtLaunch = defaults.bool(forKey: "showWindowAtLaunch")
}
+4 -28
View File
@@ -43,7 +43,7 @@ struct LauncherView: View {
case .settings:
SettingsPane(server: server, settings: settings)
case .permissions:
PermissionsPane(server: server, settings: settings, permissions: permissions)
PermissionsPane(server: server, permissions: permissions)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -86,7 +86,6 @@ struct LauncherView: View {
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: " · ")
}
@@ -101,12 +100,6 @@ struct LauncherView: View {
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)
@@ -268,7 +261,7 @@ private struct SettingsPane: View {
var body: some View {
Form {
Section("ProdDash") {
Section("Network") {
HStack {
TextField("Port", text: $portText)
.frame(width: 90)
@@ -277,13 +270,9 @@ private struct SettingsPane: View {
.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") {
Section("Startup") {
Toggle("Open ProdDash at login", isOn: Binding(
get: { loginItemOn },
set: { setLoginItem($0) }))
@@ -309,9 +298,7 @@ private struct SettingsPane: View {
}
PathRow(title: "Node.js",
value: server.nodeURL.map { "\($0.path) \(server.nodeVersion)" } ?? "Not found",
missing: server.nodeURL == nil) {
chooseNode()
}
missing: server.nodeURL == nil, action: nil)
PathRow(title: "Settings", value: server.settingsDir.isEmpty
? server.dataDir.path : server.settingsDir, missing: false, action: nil)
}
@@ -356,17 +343,6 @@ private struct SettingsPane: View {
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 {
+6 -11
View File
@@ -105,8 +105,6 @@ final class PermissionsModel: ObservableObject {
@Published private(set) var notifications: UNAuthorizationStatus = .notDetermined
@Published private(set) var hostChecks: [HostCheck] = []
@Published private(set) var checking = false
/// Plain-language read on local network access, since macOS has no API for it.
@Published private(set) var localNetworkVerdict = ""
private var browser: NWBrowser?
@@ -160,7 +158,6 @@ final class PermissionsModel: ObservableObject {
guard !checking else { return }
var checks = Self.configuredLocalHosts(root: root, dataDir: dataDir)
checking = true
localNetworkVerdict = ""
for index in checks.indices { checks[index].result = nil }
hostChecks = checks
startBonjourBrowse()
@@ -169,8 +166,7 @@ final class PermissionsModel: ObservableObject {
// Nothing configured yet: the browse alone still raises the prompt.
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in
self?.checking = false
self?.localNetworkVerdict = "No local gear is configured yet. Set up ProdCom or ProPresenter in the "
+ "admin page, then check again — approve the prompt if macOS asks."
LogStore.shared.launcher("local network check: nothing configured to try yet")
}
return
}
@@ -189,16 +185,15 @@ final class PermissionsModel: ObservableObject {
self.stopBonjourBrowse()
let results = self.hostChecks.compactMap(\.result)
let answered = results.filter(\.isReachableHost).count
let verdict: String
if answered == results.count {
self.localNetworkVerdict = "Local network access is working — every configured host answered."
verdict = "every configured host answered"
} else if answered == 0 {
self.localNetworkVerdict = "Nothing on this network answered. If the gear is powered on, macOS is "
+ "withholding local network access from ProdDash — turn it on in System Settings."
verdict = "nothing answered — if the gear is on, macOS is withholding local network access"
} else {
self.localNetworkVerdict = "\(answered) of \(results.count) hosts answered. The rest are probably "
+ "off or on another network; a blanket failure would point at the permission."
verdict = "\(answered) of \(results.count) hosts answered"
}
LogStore.shared.launcher("local network check: \(self.localNetworkVerdict)")
LogStore.shared.launcher("local network check: \(verdict)")
}
}
+161 -161
View File
@@ -3,101 +3,145 @@ import SwiftUI
import UserNotifications
/**
The permissions pane.
The permissions pane: one line per permission — a status dot, its name, and
the one thing you can do about it.
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.
What appears here is what the installed modules declare in their manifests,
so the list matches what is actually installed. Each row's tooltip carries
the modules' own reasons; the row itself stays out of the way.
*/
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)
VStack(spacing: 0) {
Form {
Section {
if permissions.needs.isEmpty {
Text("No installed module declares a permission.")
.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)
ForEach(permissions.needs) { need in
row(for: need)
}
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)
notifications
}
}
.formStyle(.grouped)
Section {
Button("Re-check") {
permissions.refresh(root: server.rootURL, dataDir: server.dataDir)
}
Button("Re-check") {
permissions.refresh(root: server.rootURL, dataDir: server.dataDir)
}
.padding(.bottom, 14)
}
.formStyle(.grouped)
.onAppear { permissions.refresh(root: server.rootURL, dataDir: server.dataDir) }
}
// MARK: - Microphone
// MARK: - Rows
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)
@ViewBuilder
private func row(for need: ModuleNeed) -> some View {
switch need.kind {
case .microphone:
PermissionRow(title: need.kind.title,
tone: microphoneTone,
help: help(for: need),
actionTitle: microphoneActionTitle,
action: microphoneActionTitle == nil ? nil : { requestMicrophone() })
case .localNetwork:
VStack(alignment: .leading, spacing: 7) {
PermissionRow(title: need.kind.title,
tone: localNetworkTone,
help: help(for: need),
busy: permissions.checking,
actionTitle: "Check Access",
action: { permissions.checkLocalNetwork(root: server.rootURL, dataDir: server.dataDir) })
if !results.isEmpty { hostResults }
}
case .other:
PermissionRow(title: need.kind.title,
tone: .unknown,
help: "The launcher doesn't manage this one — grant it in System Settings.\n"
+ help(for: need),
actionTitle: "Open System Settings",
action: { SystemSettings.open(SystemSettings.privacy) })
}
}
private var notifications: some View {
PermissionRow(title: "Notifications",
tone: notificationTone,
help: "Tells you when ProdDash stops on its own.",
actionTitle: notificationActionTitle,
action: notificationActionTitle == nil ? nil : { permissions.requestNotifications() })
}
/// What the modules said, for the tooltip — one line per distinct reason.
private func help(for need: ModuleNeed) -> String {
need.claims
.map { $0.modules.isEmpty ? $0.reason : "\($0.reason) — \($0.askedBy)" }
.joined(separator: "\n")
}
// MARK: - Local network results
private var results: [Net.Probe] { permissions.hostChecks.compactMap(\.result) }
private var hostResults: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(permissions.hostChecks) { check in
HStack(spacing: 8) {
Circle()
.fill(tone(for: check.result).color)
.frame(width: 6, height: 6)
Text(check.label)
.font(.system(.caption, design: .monospaced))
Spacer()
Text(describe(check.result))
.font(.caption)
.foregroundStyle(.secondary)
}
}
if !results.isEmpty, !results.contains(where: \.isReachableHost) {
// Only when everything failed, because that is the shape a
// withheld permission takes.
HStack(spacing: 8) {
Text("Nothing answered.")
.font(.caption)
.foregroundStyle(.secondary)
Button("Open System Settings") { SystemSettings.open(SystemSettings.localNetwork) }
.buttonStyle(.link)
.controlSize(.small)
}
}
}
.padding(.leading, 17)
}
private func describe(_ result: Net.Probe?) -> String {
switch result {
case .none: return "—"
case .open: return "answered"
case .refused: return "reachable"
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
}
// MARK: - Status and actions
private func requestMicrophone() {
permissions.requestMicrophone()
}
private var microphoneTone: Tone {
@@ -108,77 +152,19 @@ struct PermissionsPane: View {
}
}
// 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 var microphoneActionTitle: String? {
switch permissions.microphone {
case .authorized: return nil
case .notDetermined: return "Allow"
default: return "Open System Settings"
}
}
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"
}
/// Anything answering means the permission is there; only a clean sweep of
/// failures points at macOS withholding it.
private var localNetworkTone: Tone {
guard !results.isEmpty else { return .unknown }
return results.contains(where: \.isReachableHost) ? .good : .bad
}
private var notificationTone: Tone {
@@ -188,9 +174,17 @@ struct PermissionsPane: View {
default: return .unknown
}
}
private var notificationActionTitle: String? {
switch permissions.notifications {
case .authorized, .provisional, .ephemeral: return nil
case .notDetermined: return "Enable"
default: return "Open System Settings"
}
}
}
// MARK: - Small pieces
// MARK: - Pieces
enum Tone {
case good, bad, unknown
@@ -202,33 +196,39 @@ enum Tone {
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)
/// Said out loud for anyone who can't see the dot.
var word: String {
switch self {
case .good: return "allowed"
case .bad: return "not allowed"
case .unknown: return "not checked"
}
}
}
private struct NeedHeader: View {
let need: ModuleNeed
private struct PermissionRow: View {
let title: String
let tone: Tone
let help: String
var busy = false
var actionTitle: String?
var action: (() -> Void)?
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)
}
HStack(spacing: 9) {
Circle()
.fill(tone.color)
.frame(width: 8, height: 8)
.accessibilityHidden(true) // the name carries the status instead
Text(title)
.accessibilityLabel("\(title): \(tone.word)")
Spacer()
if busy { ProgressView().controlSize(.small) }
if let actionTitle, let action {
Button(actionTitle, action: action).disabled(busy)
}
}
.help(help)
}
}
@@ -47,7 +47,6 @@ final class ServerController: ObservableObject {
@Published private(set) var shellVersion = ""
@Published private(set) var localURL = ""
@Published private(set) var networkURLs: [String] = []
@Published private(set) var modulesSummary = ""
@Published private(set) var settingsDir = ""
/// The server answered /api/modules — listening, not merely alive.
@Published private(set) var healthy = false
@@ -125,7 +124,6 @@ final class ServerController: ObservableObject {
message = ""
localURL = ""
networkURLs = []
modulesSummary = ""
healthy = false
healthMisses = 0
openedDashboardThisRun = false
@@ -283,7 +281,6 @@ final class ServerController: ObservableObject {
switch field {
case "Local": localURL = value
case "Network": if !networkURLs.contains(value) { networkURLs.append(value) }
case "Modules": modulesSummary = value
case "Settings": settingsDir = value
default: break
}
@@ -330,16 +327,14 @@ final class ServerController: ObservableObject {
: "ProdDash \(how)."
let last = lastProblemLine.isEmpty ? "" : " Last message: \(lastProblemLine)"
fail(why + last)
Notifier.shared.post(title: "ProdDash stopped", body: why, if: settings.notifyOnProblem)
Notifier.shared.post(title: "ProdDash stopped", body: why)
return
}
let delay = [1.0, 2.0, 5.0, 10.0][min(crashTimes.count - 1, 3)]
state = .stopped
message = "ProdDash \(how) — restarting…"
log.launcher("ProdDash \(how)\(detail); restarting in \(Int(delay))s")
Notifier.shared.post(title: "ProdDash stopped",
body: "It \(how)\(detail). Restarting…",
if: settings.notifyOnProblem)
Notifier.shared.post(title: "ProdDash stopped", body: "It \(how)\(detail). Restarting…")
scheduleRestart(after: delay)
}
@@ -40,10 +40,9 @@ final class Notifier {
/// 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 }
/// It is asked for from the Permissions tab; until then this does nothing.
func post(title: String, body: String) {
guard available, authorized else { return }
let content = UNMutableNotificationContent()
content.title = title
content.body = body
@@ -92,4 +91,5 @@ enum SystemSettings {
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"
static let privacy = "x-apple.systempreferences:com.apple.preference.security?Privacy"
}