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,336 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
|
||||
/**
|
||||
The launcher itself: one status-bar item, one window, one server.
|
||||
|
||||
It is an accessory app (no Dock icon, no app menu), so everything it can do
|
||||
is reachable from the menu-bar menu — including quitting, which stops the
|
||||
server with it.
|
||||
*/
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
||||
|
||||
static var version: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0"
|
||||
}
|
||||
/// Sent by a second copy of the launcher so the first one shows itself.
|
||||
static let showNotification = Notification.Name("org.waterschurch.proddash.launcher.show")
|
||||
|
||||
let settings = LauncherSettings()
|
||||
let permissions = PermissionsModel()
|
||||
lazy var server = ServerController(settings: settings)
|
||||
|
||||
private var statusItem: NSStatusItem?
|
||||
private var windowController: LauncherWindowController?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var appearanceObserver: NSKeyValueObservation?
|
||||
private var signalSources: [DispatchSourceSignal] = []
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
guard !handOffToRunningInstance() else { return }
|
||||
|
||||
LogStore.shared.launcher("ProdDash Launcher \(Self.version)")
|
||||
setUpStatusItem()
|
||||
|
||||
server.onPortBusy = { [weak self] port, isProdDash, reply in
|
||||
self?.askAboutBusyPort(port, isProdDash: isProdDash, reply: reply)
|
||||
}
|
||||
// Any change in the server's state re-draws the icon; the menu rebuilds
|
||||
// itself when it opens.
|
||||
server.objectWillChange
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in self?.updateStatusItem() }
|
||||
.store(in: &cancellables)
|
||||
|
||||
DistributedNotificationCenter.default().addObserver(
|
||||
self, selector: #selector(showWindow), name: Self.showNotification, object: nil)
|
||||
|
||||
catchShutdownSignals()
|
||||
permissions.refresh(root: server.rootURL, dataDir: server.dataDir)
|
||||
updateStatusItem()
|
||||
|
||||
let firstRun = !settings.hasRunBefore
|
||||
settings.hasRunBefore = true
|
||||
|
||||
if settings.showWindowAtLaunch || firstRun { showWindow() }
|
||||
if settings.startServerAtLaunch { server.start() }
|
||||
|
||||
// Owning the permissions means asking for them at a sensible moment —
|
||||
// now, with someone at the keyboard — rather than halfway through a
|
||||
// service when a module first reaches for the microphone.
|
||||
if firstRun { requestDeclaredPermissions() }
|
||||
}
|
||||
|
||||
/// Something else is quitting us — a logout, a restart, `osascript … quit`.
|
||||
/// Stop the server first, but never let that wedge a logout: the backstop
|
||||
/// leaves anyway if the reply hasn't gone out in time.
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
guard server.state.isBusy else { return .terminateNow }
|
||||
LogStore.shared.launcher("quitting — stopping ProdDash first")
|
||||
server.stop()
|
||||
replyWhenStopped(within: 36)
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 12) { exit(0) }
|
||||
return .terminateLater
|
||||
}
|
||||
|
||||
/// Waiting here with a Timer would hang the quit outright: while the app
|
||||
/// sits on `.terminateLater` the run loop is not in its default mode, so a
|
||||
/// scheduled timer never fires.
|
||||
private func replyWhenStopped(within ticks: Int) {
|
||||
guard server.state.isBusy, ticks > 0 else {
|
||||
return NSApp.reply(toApplicationShouldTerminate: true)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
||||
self?.replyWhenStopped(within: ticks - 1)
|
||||
}
|
||||
}
|
||||
|
||||
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { true }
|
||||
|
||||
/// A Cocoa app doesn't run its normal quit path when something sends it a
|
||||
/// signal — `killall`, a logout, a stop from a terminal. Without this the
|
||||
/// launcher would vanish and leave node (and its audio capture) running.
|
||||
private func catchShutdownSignals() {
|
||||
for number in [SIGTERM, SIGINT, SIGHUP] {
|
||||
signal(number, SIG_IGN)
|
||||
let source = DispatchSource.makeSignalSource(signal: number, queue: .main)
|
||||
source.setEventHandler { [weak self] in
|
||||
LogStore.shared.launcher("signal \(number) — stopping ProdDash and quitting")
|
||||
self?.server.stop()
|
||||
self?.exitOnceStopped(within: 32)
|
||||
}
|
||||
source.resume()
|
||||
signalSources.append(source)
|
||||
}
|
||||
}
|
||||
|
||||
private func exitOnceStopped(within ticks: Int) {
|
||||
guard server.state.isBusy, ticks > 0 else { exit(0) }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
||||
self?.exitOnceStopped(within: ticks - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// A second launch just shows the copy that is already running.
|
||||
private func handOffToRunningInstance() -> Bool {
|
||||
guard let id = Bundle.main.bundleIdentifier else { return false }
|
||||
let mine = ProcessInfo.processInfo.processIdentifier
|
||||
let others = NSRunningApplication.runningApplications(withBundleIdentifier: id)
|
||||
.filter { $0.processIdentifier != mine }
|
||||
guard !others.isEmpty else { return false }
|
||||
DistributedNotificationCenter.default().post(name: Self.showNotification, object: nil)
|
||||
NSApp.terminate(nil)
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Status item
|
||||
|
||||
private func setUpStatusItem() {
|
||||
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
let menu = NSMenu()
|
||||
menu.delegate = self
|
||||
menu.autoenablesItems = false
|
||||
item.menu = menu
|
||||
statusItem = item
|
||||
// The glyph is drawn, not templated, so it has to be redrawn when the
|
||||
// menu bar switches between light and dark.
|
||||
appearanceObserver = item.button?.observe(\.effectiveAppearance) { [weak self] _, _ in
|
||||
DispatchQueue.main.async { self?.updateStatusItem() }
|
||||
}
|
||||
}
|
||||
|
||||
private func updateStatusItem() {
|
||||
guard let button = statusItem?.button else { return }
|
||||
button.image = StatusIcon.image(for: server.state, healthy: server.healthy)
|
||||
button.toolTip = "ProdDash — \(server.state.label)"
|
||||
}
|
||||
|
||||
// MARK: - Menu
|
||||
|
||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||
menu.removeAllItems()
|
||||
|
||||
let version = server.shellVersion.isEmpty ? "" : " \(server.shellVersion)"
|
||||
menu.addItem(disabled("ProdDash\(version) — \(server.state.label)"))
|
||||
switch server.state {
|
||||
case .running:
|
||||
menu.addItem(disabled("localhost:\(server.port)"))
|
||||
case .failed(let why):
|
||||
menu.addItem(disabled(why.prefix(60) + (why.count > 60 ? "…" : "")))
|
||||
default:
|
||||
if !server.message.isEmpty { menu.addItem(disabled(String(server.message.prefix(60)))) }
|
||||
}
|
||||
|
||||
menu.addItem(.separator())
|
||||
let dashboard = item("Open Dashboard", #selector(openDashboard), key: "d")
|
||||
dashboard.isEnabled = server.state == .running
|
||||
menu.addItem(dashboard)
|
||||
let admin = item("Open Admin", #selector(openAdmin), key: "a")
|
||||
admin.isEnabled = server.state == .running
|
||||
menu.addItem(admin)
|
||||
|
||||
menu.addItem(.separator())
|
||||
switch server.state {
|
||||
case .running, .starting:
|
||||
menu.addItem(item("Stop ProdDash", #selector(toggleServer)))
|
||||
case .stopping:
|
||||
menu.addItem(disabled("Stopping…"))
|
||||
case .stopped, .failed:
|
||||
menu.addItem(item("Start ProdDash", #selector(toggleServer)))
|
||||
}
|
||||
let restart = item("Restart ProdDash", #selector(restartServer))
|
||||
restart.isEnabled = server.state == .running || server.state == .starting
|
||||
menu.addItem(restart)
|
||||
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(item("Launcher…", #selector(showWindow), key: ","))
|
||||
let login = item("Open at Login", #selector(toggleLoginItem))
|
||||
login.state = LoginItem.isEnabled ? .on : .off
|
||||
menu.addItem(login)
|
||||
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(item("Quit ProdDash", #selector(quit), key: "q"))
|
||||
}
|
||||
|
||||
private func item(_ title: String, _ action: Selector, key: String = "") -> NSMenuItem {
|
||||
let item = NSMenuItem(title: title, action: action, keyEquivalent: key)
|
||||
item.target = self
|
||||
item.isEnabled = true
|
||||
return item
|
||||
}
|
||||
|
||||
private func disabled(_ title: any StringProtocol) -> NSMenuItem {
|
||||
let item = NSMenuItem(title: String(title), action: nil, keyEquivalent: "")
|
||||
item.isEnabled = false
|
||||
return item
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc private func openDashboard() { server.openDashboard() }
|
||||
@objc private func openAdmin() { server.openAdmin() }
|
||||
@objc private func toggleServer() { server.toggle() }
|
||||
@objc private func restartServer() { server.restart() }
|
||||
|
||||
@objc func showWindow() {
|
||||
if windowController == nil {
|
||||
windowController = LauncherWindowController(delegate: self)
|
||||
}
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
windowController?.showWindow(nil)
|
||||
windowController?.window?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
/// Close the window but stay in the menu bar.
|
||||
func hideWindow() {
|
||||
windowController?.window?.orderOut(nil)
|
||||
}
|
||||
|
||||
@objc private func toggleLoginItem() {
|
||||
let turningOn = !LoginItem.isEnabled
|
||||
if let error = LoginItem.set(turningOn) {
|
||||
present(alert: "Couldn't change the login item", detail: error)
|
||||
return
|
||||
}
|
||||
LogStore.shared.launcher("open at login: \(turningOn ? "on" : "off")")
|
||||
if turningOn && LoginItem.needsApproval {
|
||||
present(alert: "Approve ProdDash in Login Items",
|
||||
detail: "macOS needs you to allow ProdDash under System Settings → General → Login Items.",
|
||||
openSettings: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// A modal run started while the menu is still closing doesn't reliably
|
||||
/// appear — let the menu finish first, then ask.
|
||||
@objc func quit() {
|
||||
DispatchQueue.main.async { [weak self] in self?.confirmThenQuit() }
|
||||
}
|
||||
|
||||
private func confirmThenQuit() {
|
||||
// A booth machine shouldn't lose its dashboard to a stray click.
|
||||
if server.state == .running || server.state == .starting {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Quit ProdDash?"
|
||||
alert.informativeText = "The server stops with the launcher, and every dashboard on the network goes dark."
|
||||
alert.addButton(withTitle: "Quit")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
alert.alertStyle = .warning
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
}
|
||||
// Deliberately not NSApp.terminate: begun here, while the alert's modal
|
||||
// session is still unwinding, it leaves AppKit wedged with the main
|
||||
// queue unserviced — the server stops and the app never finishes
|
||||
// quitting. Stopping is our own job anyway, so do it and leave.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { exit(0) }
|
||||
LogStore.shared.launcher("quitting — stopping ProdDash first")
|
||||
self.server.stop()
|
||||
self.exitOnceStopped(within: 36)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prompts
|
||||
|
||||
/// Something already holds the port. If it answers like ProdDash, offer the
|
||||
/// same hand-over Start ProdDash.command does — that other copy was very
|
||||
/// likely started over SSH and has no microphone access.
|
||||
private func askAboutBusyPort(_ port: Int, isProdDash: Bool, reply: @escaping (Bool) -> Void) {
|
||||
let alert = NSAlert()
|
||||
if isProdDash {
|
||||
alert.messageText = "ProdDash is already running on port \(port)"
|
||||
alert.informativeText = "It was started outside this launcher — over SSH or from Start ProdDash.command "
|
||||
+ "— so it has no microphone access and this app can't watch over it. Take it over?"
|
||||
alert.addButton(withTitle: "Take Over")
|
||||
} else {
|
||||
alert.messageText = "Port \(port) is already in use"
|
||||
alert.informativeText = "Another app is listening on port \(port). Stop that app, or give ProdDash a "
|
||||
+ "different port under Settings. Stopping whatever holds it anyway is rarely what you want."
|
||||
alert.addButton(withTitle: "Stop It Anyway")
|
||||
}
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
alert.alertStyle = isProdDash ? .informational : .warning
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
reply(alert.runModal() == .alertFirstButtonReturn)
|
||||
}
|
||||
|
||||
/// 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:
|
||||
if permissions.microphone == .notDetermined { permissions.requestMicrophone() }
|
||||
case .localNetwork:
|
||||
permissions.checkLocalNetwork(root: server.rootURL, dataDir: server.dataDir)
|
||||
case .other:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deferred for the same reason the quit confirmation is: a modal run
|
||||
/// started while the menu is still closing doesn't reliably appear.
|
||||
private func present(alert title: String, detail: String, openSettings: Bool = false) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.runAlert(title: title, detail: detail, openSettings: openSettings)
|
||||
}
|
||||
}
|
||||
|
||||
private func runAlert(title: String, detail: String, openSettings: Bool) {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = title
|
||||
alert.informativeText = detail
|
||||
alert.addButton(withTitle: openSettings ? "Open System Settings" : "OK")
|
||||
if openSettings { alert.addButton(withTitle: "Later") }
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if alert.runModal() == .alertFirstButtonReturn && openSettings {
|
||||
LoginItem.openSystemSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
The ProdDash server process, spawned into its OWN process group.
|
||||
|
||||
Why not Foundation's `Process`: the server starts children of its own — the
|
||||
Timers module runs `ltc-capture`, which holds an audio input device open. If
|
||||
the launcher force-kills only node, that capture tool is orphaned and keeps
|
||||
the device (and the microphone indicator) until someone hunts it down in
|
||||
Activity Monitor. `posix_spawn` with POSIX_SPAWN_SETPGROUP makes the server a
|
||||
process-group leader, so `kill(-pid, …)` reaches the whole tree.
|
||||
|
||||
Output arrives line by line on the main queue; `onExit` fires once, also on
|
||||
main, with the exit code and whether a signal killed it.
|
||||
*/
|
||||
final class ChildProcess {
|
||||
|
||||
/// A spawn that never got off the ground, with something an operator can act on.
|
||||
struct SpawnError: LocalizedError {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
let pid: pid_t
|
||||
/// What we ran, for the log.
|
||||
let commandLine: String
|
||||
|
||||
/// Held for the life of the child: a FileHandle's readability handler does
|
||||
/// not retain it, so letting these go closes the pipes and the output
|
||||
/// silently stops.
|
||||
private let outPipe: Pipe
|
||||
private let errPipe: Pipe
|
||||
private var exitSource: DispatchSourceProcess?
|
||||
private var terminated = false
|
||||
|
||||
private init(pid: pid_t, commandLine: String, outPipe: Pipe, errPipe: Pipe) {
|
||||
self.pid = pid
|
||||
self.commandLine = commandLine
|
||||
self.outPipe = outPipe
|
||||
self.errPipe = errPipe
|
||||
}
|
||||
|
||||
deinit { releasePipes() }
|
||||
|
||||
// MARK: - Spawning
|
||||
|
||||
static func run(executable: String,
|
||||
arguments: [String],
|
||||
directory: String,
|
||||
environment: [String: String],
|
||||
onLine: @escaping (String, Bool) -> Void,
|
||||
onExit: @escaping (Int32, Bool) -> Void) throws -> ChildProcess {
|
||||
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
|
||||
var actions: posix_spawn_file_actions_t?
|
||||
posix_spawn_file_actions_init(&actions)
|
||||
defer { posix_spawn_file_actions_destroy(&actions) }
|
||||
// The server never reads stdin; /dev/null keeps a stray read from blocking it.
|
||||
posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0)
|
||||
posix_spawn_file_actions_adddup2(&actions, outPipe.fileHandleForWriting.fileDescriptor, 1)
|
||||
posix_spawn_file_actions_adddup2(&actions, errPipe.fileHandleForWriting.fileDescriptor, 2)
|
||||
posix_spawn_file_actions_addchdir_np(&actions, directory)
|
||||
|
||||
var attr: posix_spawnattr_t?
|
||||
posix_spawnattr_init(&attr)
|
||||
defer { posix_spawnattr_destroy(&attr) }
|
||||
// SETPGROUP + pgroup 0: the child leads its own group (see the note above).
|
||||
// CLOEXEC_DEFAULT: nothing but the three descriptors above is inherited.
|
||||
posix_spawnattr_setflags(&attr, Int16(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_CLOEXEC_DEFAULT))
|
||||
posix_spawnattr_setpgroup(&attr, 0)
|
||||
|
||||
let argv: [UnsafeMutablePointer<CChar>?] = ([executable] + arguments).map { strdup($0) } + [nil]
|
||||
let envp: [UnsafeMutablePointer<CChar>?] = environment
|
||||
.map { strdup("\($0.key)=\($0.value)") } + [nil]
|
||||
defer {
|
||||
for p in argv where p != nil { free(p) }
|
||||
for p in envp where p != nil { free(p) }
|
||||
}
|
||||
|
||||
var pid: pid_t = 0
|
||||
let rc = posix_spawn(&pid, executable, &actions, &attr, argv, envp)
|
||||
guard rc == 0 else {
|
||||
outPipe.closeBothEnds()
|
||||
errPipe.closeBothEnds()
|
||||
throw SpawnError(message: "could not start \(executable): \(String(cString: strerror(rc))) (\(rc))")
|
||||
}
|
||||
|
||||
let child = ChildProcess(pid: pid,
|
||||
commandLine: ([executable] + arguments).joined(separator: " "),
|
||||
outPipe: outPipe, errPipe: errPipe)
|
||||
|
||||
// Our copies of the write ends must go, or the reads never see EOF.
|
||||
try? outPipe.fileHandleForWriting.close()
|
||||
try? errPipe.fileHandleForWriting.close()
|
||||
child.pump(outPipe.fileHandleForReading, isError: false, onLine: onLine)
|
||||
child.pump(errPipe.fileHandleForReading, isError: true, onLine: onLine)
|
||||
|
||||
let source = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit, queue: .main)
|
||||
source.setEventHandler { [weak child] in
|
||||
var status: Int32 = 0
|
||||
waitpid(pid, &status, 0)
|
||||
child?.exitSource?.cancel()
|
||||
child?.exitSource = nil
|
||||
// Give the last lines a moment to arrive before letting the pipes go.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { child?.releasePipes() }
|
||||
// Low 7 bits hold the signal that killed it; 0 means a normal exit.
|
||||
let signal = status & 0x7f
|
||||
if signal == 0 {
|
||||
onExit((status >> 8) & 0xff, false)
|
||||
} else {
|
||||
onExit(signal, true)
|
||||
}
|
||||
}
|
||||
child.exitSource = source
|
||||
source.resume()
|
||||
return child
|
||||
}
|
||||
|
||||
/// Deliver whole lines on the main queue; partial trailing text waits for the rest.
|
||||
private func pump(_ handle: FileHandle, isError: Bool, onLine: @escaping (String, Bool) -> Void) {
|
||||
var buffer = Data()
|
||||
handle.readabilityHandler = { file in
|
||||
let chunk = file.availableData
|
||||
if chunk.isEmpty { // EOF — the child closed this end
|
||||
file.readabilityHandler = nil
|
||||
if !buffer.isEmpty, let tail = String(data: buffer, encoding: .utf8) {
|
||||
DispatchQueue.main.async { onLine(tail, isError) }
|
||||
}
|
||||
try? file.close()
|
||||
return
|
||||
}
|
||||
buffer.append(chunk)
|
||||
var lines: [String] = []
|
||||
while let nl = buffer.firstIndex(of: 0x0a) {
|
||||
let raw = buffer.subdata(in: buffer.startIndex..<nl)
|
||||
buffer.removeSubrange(buffer.startIndex...nl)
|
||||
lines.append(String(data: raw, encoding: .utf8) ?? "")
|
||||
}
|
||||
if lines.isEmpty { return }
|
||||
DispatchQueue.main.async { for line in lines { onLine(line, isError) } }
|
||||
}
|
||||
}
|
||||
|
||||
private func releasePipes() {
|
||||
for handle in [outPipe.fileHandleForReading, errPipe.fileHandleForReading] {
|
||||
handle.readabilityHandler = nil
|
||||
try? handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stopping
|
||||
|
||||
var isAlive: Bool { kill(pid, 0) == 0 }
|
||||
|
||||
/// SIGTERM to the whole group. The server unmounts its modules and exits
|
||||
/// within ~1.5 s; `forceKill()` is the backstop if it doesn't.
|
||||
func terminate() {
|
||||
guard !terminated else { return }
|
||||
terminated = true
|
||||
signalGroup(SIGTERM)
|
||||
}
|
||||
|
||||
func forceKill() {
|
||||
signalGroup(SIGKILL)
|
||||
}
|
||||
|
||||
private func signalGroup(_ sig: Int32) {
|
||||
// Only address the group when the child really leads one — otherwise a
|
||||
// negative pid could reach processes that are none of our business.
|
||||
if getpgid(pid) == pid {
|
||||
kill(-pid, sig)
|
||||
} else {
|
||||
kill(pid, sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Pipe {
|
||||
func closeBothEnds() {
|
||||
try? fileHandleForReading.close()
|
||||
try? fileHandleForWriting.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
Where things are, and which settings belong to whom.
|
||||
|
||||
Two kinds of setting live in this app, and they are deliberately kept apart:
|
||||
|
||||
* **ProdDash settings** (the port) belong to ProdDash, so they are written
|
||||
where ProdDash already looks for them — `proddash.json` in the per-machine
|
||||
data directory. Start the server any other way (`Start ProdDash.command`,
|
||||
a terminal) and it still comes up on the port set here.
|
||||
* **Launcher settings** (auto-start, restart-on-crash, where the checkout
|
||||
lives) belong to this app and live in its own preferences.
|
||||
|
||||
The data-directory and port rules mirror server.js exactly — if that file's
|
||||
`resolveDataDir` / `PORT` logic changes, change it here too.
|
||||
*/
|
||||
enum ProdDash {
|
||||
|
||||
// MARK: - Data directory (mirrors server.js resolveDataDir)
|
||||
|
||||
/// `PRODDASH_DATA_DIR` if the environment sets one (relative paths resolve
|
||||
/// against the checkout, as in server.js), else the per-machine default.
|
||||
static func dataDir(root: URL?) -> URL {
|
||||
let fromEnv = (ProcessInfo.processInfo.environment["PRODDASH_DATA_DIR"] ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !fromEnv.isEmpty {
|
||||
if fromEnv.hasPrefix("/") { return URL(fileURLWithPath: fromEnv) }
|
||||
if let root { return root.appendingPathComponent(fromEnv).standardizedFileURL }
|
||||
}
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/ProdDash")
|
||||
}
|
||||
|
||||
// MARK: - Finding the checkout
|
||||
|
||||
/// A folder is ProdDash if it has the two files the server cannot run without.
|
||||
static func isRoot(_ url: URL) -> Bool {
|
||||
let fm = FileManager.default
|
||||
return fm.fileExists(atPath: url.appendingPathComponent("server.js").path)
|
||||
&& fm.fileExists(atPath: url.appendingPathComponent("public/index.html").path)
|
||||
}
|
||||
|
||||
/**
|
||||
Find the ProdDash folder: the one the operator chose, else the checkout
|
||||
this .app was built inside (the app usually sits in `launcher/macos/build`),
|
||||
else the handful of places people actually keep it.
|
||||
*/
|
||||
static func findRoot(preferred: String?) -> URL? {
|
||||
if let preferred, !preferred.isEmpty {
|
||||
let url = URL(fileURLWithPath: preferred).standardizedFileURL
|
||||
if isRoot(url) { return url }
|
||||
}
|
||||
if let env = ProcessInfo.processInfo.environment["PRODDASH_ROOT"], !env.isEmpty {
|
||||
let url = URL(fileURLWithPath: env).standardizedFileURL
|
||||
if isRoot(url) { return url }
|
||||
}
|
||||
var up = Bundle.main.bundleURL.standardizedFileURL
|
||||
for _ in 0..<6 {
|
||||
up.deleteLastPathComponent()
|
||||
if up.path == "/" { break }
|
||||
if isRoot(up) { return up }
|
||||
}
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||
var candidates = [
|
||||
home.appendingPathComponent("Apps/ProdDash"),
|
||||
home.appendingPathComponent("ProdDash"),
|
||||
home.appendingPathComponent("Documents/ProdDash"),
|
||||
URL(fileURLWithPath: "/Applications/ProdDash"),
|
||||
URL(fileURLWithPath: "/Users/Shared/ProdDash"),
|
||||
]
|
||||
// …and next to the app itself, for a "ProdDash Launcher.app beside the folder" layout.
|
||||
candidates.append(Bundle.main.bundleURL.deletingLastPathComponent()
|
||||
.appendingPathComponent("ProdDash"))
|
||||
return candidates.first(where: isRoot)?.standardizedFileURL
|
||||
}
|
||||
|
||||
/// The shell's version, straight from the checkout's package.json.
|
||||
static func shellVersion(root: URL) -> String {
|
||||
guard let data = try? Data(contentsOf: root.appendingPathComponent("package.json")),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let version = json["version"] as? String else { return "" }
|
||||
return version
|
||||
}
|
||||
|
||||
// MARK: - Finding node
|
||||
|
||||
/// Everywhere a Mac plausibly keeps node, in the order worth trying.
|
||||
static func findNode(root: URL?, preferred: String?) -> URL? {
|
||||
var tries: [URL] = []
|
||||
if let preferred, !preferred.isEmpty { tries.append(URL(fileURLWithPath: preferred)) }
|
||||
// The runtime a production machine ships with — same preference as Start ProdDash.command.
|
||||
if let root { tries.append(root.appendingPathComponent("runtime/bin/node")) }
|
||||
tries += ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/opt/local/bin/node", "/usr/bin/node"]
|
||||
.map { URL(fileURLWithPath: $0) }
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||
tries += newestNodeUnder(home.appendingPathComponent(".nvm/versions/node"))
|
||||
tries += [home.appendingPathComponent(".volta/bin/node")]
|
||||
if let found = tries.first(where: isExecutable) { return found }
|
||||
// Last resort: ask a login shell, which is where a fancier version
|
||||
// manager (asdf, fnm, mise) puts node on the PATH.
|
||||
if let fromShell = loginShellNode(), isExecutable(fromShell) { return fromShell }
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func isExecutable(_ url: URL) -> Bool {
|
||||
FileManager.default.isExecutableFile(atPath: url.path)
|
||||
}
|
||||
|
||||
private static func newestNodeUnder(_ dir: URL) -> [URL] {
|
||||
guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else { return [] }
|
||||
return names.sorted { compareVersions($0, $1) > 0 }
|
||||
.map { dir.appendingPathComponent($0).appendingPathComponent("bin/node") }
|
||||
}
|
||||
|
||||
private static func loginShellNode() -> URL? {
|
||||
let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
|
||||
let task = Process()
|
||||
task.executableURL = URL(fileURLWithPath: shell)
|
||||
task.arguments = ["-lc", "command -v node"]
|
||||
let pipe = Pipe()
|
||||
task.standardOutput = pipe
|
||||
task.standardError = FileHandle.nullDevice
|
||||
do { try task.run() } catch { return nil }
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
task.waitUntilExit()
|
||||
let path = String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return path.hasPrefix("/") ? URL(fileURLWithPath: path) : nil
|
||||
}
|
||||
|
||||
/// `node -v`, so the window can say which runtime is about to be used.
|
||||
static func nodeVersion(_ node: URL) -> String {
|
||||
let task = Process()
|
||||
task.executableURL = node
|
||||
task.arguments = ["-v"]
|
||||
let pipe = Pipe()
|
||||
task.standardOutput = pipe
|
||||
task.standardError = FileHandle.nullDevice
|
||||
do { try task.run() } catch { return "" }
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
task.waitUntilExit()
|
||||
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
}
|
||||
|
||||
/// node 18 is the floor the server's package.json declares.
|
||||
static func nodeIsTooOld(_ version: String) -> Bool {
|
||||
let major = Int(version.dropFirst(version.hasPrefix("v") ? 1 : 0)
|
||||
.prefix(while: { $0.isNumber })) ?? 0
|
||||
return major > 0 && major < 18
|
||||
}
|
||||
|
||||
// MARK: - The port (mirrors server.js: PORT env, then data dir, then checkout, then 24500)
|
||||
|
||||
static let defaultPort = 24500
|
||||
|
||||
static func port(root: URL?, dataDir: URL) -> Int {
|
||||
if let fromFile = jsonNumber(at: dataDir.appendingPathComponent("proddash.json"), key: "port") {
|
||||
return fromFile
|
||||
}
|
||||
if let root, let fromRepo = jsonNumber(at: root.appendingPathComponent("config/proddash.json"), key: "port") {
|
||||
return fromRepo
|
||||
}
|
||||
return defaultPort
|
||||
}
|
||||
|
||||
/**
|
||||
Write the port into the data directory's proddash.json, preserving every
|
||||
other key (the admin passcode lives in the same file). The launcher also
|
||||
passes PORT to the server it starts, so this write is what keeps a start
|
||||
by any other means on the same port.
|
||||
*/
|
||||
static func setPort(_ port: Int, dataDir: URL) throws {
|
||||
let file = dataDir.appendingPathComponent("proddash.json")
|
||||
var object: [String: Any] = [:]
|
||||
if let data = try? Data(contentsOf: file),
|
||||
let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
object = existing
|
||||
}
|
||||
object["port"] = port
|
||||
try FileManager.default.createDirectory(at: dataDir, withIntermediateDirectories: true)
|
||||
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
|
||||
// Same atomic-ish write the server uses: never leave a half-written config.
|
||||
let tmp = file.appendingPathExtension("tmp")
|
||||
try Data((inHouseStyle(data) + "\n").utf8).write(to: tmp)
|
||||
_ = try FileManager.default.replaceItemAt(file, withItemAt: tmp)
|
||||
}
|
||||
|
||||
/// Foundation writes `"port" : 24500`; the server writes `"port": 24500`.
|
||||
/// This file is read and hand-edited by people — keep one house style.
|
||||
private static func inHouseStyle(_ json: Data) -> String {
|
||||
(String(data: json, encoding: .utf8) ?? "")
|
||||
.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
.map { line in
|
||||
guard line.trimmingCharacters(in: .whitespaces).hasPrefix("\""),
|
||||
let colon = line.range(of: "\" : ") else { return String(line) }
|
||||
return line.replacingCharacters(in: colon, with: "\": ")
|
||||
}
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
static func isValidPort(_ port: Int) -> Bool { port >= 1 && port <= 65535 }
|
||||
|
||||
private static func jsonNumber(at url: URL, key: String) -> Int? {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let value = json[key] as? NSNumber else { return nil }
|
||||
let port = value.intValue
|
||||
return isValidPort(port) ? port : nil
|
||||
}
|
||||
|
||||
// MARK: - Misc
|
||||
|
||||
/// "1.10.0" > "1.9.0" — the numeric comparison version strings deserve.
|
||||
static func compareVersions(_ a: String, _ b: String) -> Int {
|
||||
let lhs = a.split(separator: ".").map { Int($0.prefix(while: \.isNumber)) ?? 0 }
|
||||
let rhs = b.split(separator: ".").map { Int($0.prefix(while: \.isNumber)) ?? 0 }
|
||||
for i in 0..<max(lhs.count, rhs.count) {
|
||||
let l = i < lhs.count ? lhs[i] : 0
|
||||
let r = i < rhs.count ? rhs[i] : 0
|
||||
if l != r { return l < r ? -1 : 1 }
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
/// The launcher's own preferences — everything that is about *running* ProdDash
|
||||
/// rather than about ProdDash itself. (The port is not here: it belongs to
|
||||
/// ProdDash and is written to its proddash.json — see LauncherConfig.)
|
||||
final class LauncherSettings: ObservableObject {
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
|
||||
@Published var rootPath: String { didSet { defaults.set(rootPath, forKey: "rootPath") } }
|
||||
@Published var nodePath: String { didSet { defaults.set(nodePath, forKey: "nodePath") } }
|
||||
/// Start the server as soon as the launcher opens (so a login item brings the
|
||||
/// whole dashboard up by itself).
|
||||
@Published var startServerAtLaunch: Bool { didSet { defaults.set(startServerAtLaunch, forKey: "startServerAtLaunch") } }
|
||||
/// 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
|
||||
/// the menu bar (Companion's "start minimised").
|
||||
@Published var showWindowAtLaunch: Bool { didSet { defaults.set(showWindowAtLaunch, forKey: "showWindowAtLaunch") } }
|
||||
|
||||
init() {
|
||||
defaults.register(defaults: [
|
||||
"startServerAtLaunch": true,
|
||||
"restartOnCrash": true,
|
||||
"notifyOnProblem": true,
|
||||
"openDashboardOnStart": false,
|
||||
"showWindowAtLaunch": true,
|
||||
])
|
||||
rootPath = defaults.string(forKey: "rootPath") ?? ""
|
||||
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")
|
||||
}
|
||||
|
||||
/// False until the launcher has run once — used to open the window (and ask
|
||||
/// for the permissions ProdDash needs) the first time only.
|
||||
var hasRunBefore: Bool {
|
||||
get { defaults.bool(forKey: "hasRunBefore") }
|
||||
set { defaults.set(newValue, forKey: "hasRunBefore") }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// The launcher's one window. Closing it leaves the app in the menu bar —
|
||||
/// the same "Hide, don't Quit" habit Companion's launcher asks for.
|
||||
final class LauncherWindowController: NSWindowController {
|
||||
|
||||
convenience init(delegate: AppDelegate) {
|
||||
let view = LauncherView(server: delegate.server,
|
||||
settings: delegate.settings,
|
||||
permissions: delegate.permissions,
|
||||
onHide: { [weak delegate] in delegate?.hideWindow() },
|
||||
onQuit: { [weak delegate] in delegate?.quit() })
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 580, height: 740),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered, defer: false)
|
||||
window.title = "ProdDash"
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.isMovableByWindowBackground = true
|
||||
window.contentView = NSHostingView(rootView: view)
|
||||
window.center()
|
||||
window.setFrameAutosaveName("ProdDashLauncherWindow")
|
||||
self.init(window: window)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
Everything the server prints, plus what the launcher did and why.
|
||||
|
||||
Kept twice over: the last few hundred lines in memory for the window's Log
|
||||
tab, and a dated file under ~/Library/Logs/ProdDash so a problem from last
|
||||
Sunday can still be read on Tuesday. Files older than a week are removed on
|
||||
startup — the same retention Companion's launcher keeps.
|
||||
*/
|
||||
final class LogStore: ObservableObject {
|
||||
|
||||
enum Kind { case launcher, out, err }
|
||||
|
||||
struct Line: Identifiable {
|
||||
let id = UUID()
|
||||
let at: Date
|
||||
let text: String
|
||||
let kind: Kind
|
||||
}
|
||||
|
||||
static let shared = LogStore()
|
||||
|
||||
/// Enough to cover a start-up and a fault, not enough to bloat the window.
|
||||
private let capacity = 800
|
||||
private let retentionDays = 7
|
||||
|
||||
@Published private(set) var lines: [Line] = []
|
||||
|
||||
private let queue = DispatchQueue(label: "org.waterschurch.proddash.launcher.log")
|
||||
private var handle: FileHandle?
|
||||
private var handleDay: String = ""
|
||||
|
||||
private lazy var directory: URL = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Logs/ProdDash")
|
||||
|
||||
private init() {
|
||||
queue.async { [weak self] in self?.pruneOldFiles() }
|
||||
}
|
||||
|
||||
/// The launcher's own voice in the log — start/stop decisions, failures.
|
||||
func launcher(_ text: String) { append(text, kind: .launcher) }
|
||||
|
||||
func append(_ text: String, kind: Kind) {
|
||||
let line = Line(at: Date(), text: text, kind: kind)
|
||||
if Thread.isMainThread {
|
||||
store(line)
|
||||
} else {
|
||||
DispatchQueue.main.async { self.store(line) }
|
||||
}
|
||||
queue.async { [weak self] in self?.write(line) }
|
||||
}
|
||||
|
||||
private func store(_ line: Line) {
|
||||
lines.append(line)
|
||||
if lines.count > capacity { lines.removeFirst(lines.count - capacity) }
|
||||
}
|
||||
|
||||
func clear() {
|
||||
lines.removeAll()
|
||||
}
|
||||
|
||||
/// Today's file, for "Reveal in Finder".
|
||||
var currentFile: URL { directory.appendingPathComponent("proddash-\(Self.dayFormatter.string(from: Date())).log") }
|
||||
|
||||
var plainText: String {
|
||||
lines.map { "\(Self.timeFormatter.string(from: $0.at)) \($0.text)" }.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// MARK: - File
|
||||
|
||||
private func write(_ line: Line) {
|
||||
let day = Self.dayFormatter.string(from: line.at)
|
||||
if handle == nil || handleDay != day {
|
||||
try? handle?.close()
|
||||
handle = nil
|
||||
handleDay = day
|
||||
let file = directory.appendingPathComponent("proddash-\(day).log")
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
if !FileManager.default.fileExists(atPath: file.path) {
|
||||
FileManager.default.createFile(atPath: file.path, contents: nil)
|
||||
}
|
||||
handle = try? FileHandle(forWritingTo: file)
|
||||
_ = try? handle?.seekToEnd()
|
||||
}
|
||||
let prefix = line.kind == .launcher ? "[launcher] " : ""
|
||||
let text = "\(Self.stampFormatter.string(from: line.at)) \(prefix)\(line.text)\n"
|
||||
try? handle?.write(contentsOf: Data(text.utf8))
|
||||
}
|
||||
|
||||
private func pruneOldFiles() {
|
||||
let cutoff = Date().addingTimeInterval(-Double(retentionDays) * 86400)
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(
|
||||
at: directory, includingPropertiesForKeys: [.contentModificationDateKey]) else { return }
|
||||
for file in files where file.lastPathComponent.hasPrefix("proddash-") {
|
||||
let modified = (try? file.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate
|
||||
if let modified, modified < cutoff { try? FileManager.default.removeItem(at: file) }
|
||||
}
|
||||
}
|
||||
|
||||
private static let dayFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "HH:mm:ss"
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let stampFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
return f
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/**
|
||||
Small connection probes, used for two jobs:
|
||||
|
||||
* is something already listening on ProdDash's port (a server started over
|
||||
SSH or from the .command file, which the launcher offers to take over), and
|
||||
* can this machine actually reach the gear the modules are configured for —
|
||||
which on macOS 15+ is the only practical way to tell whether the Local
|
||||
Network permission was granted, because there is no API that reports it.
|
||||
|
||||
Network.framework rather than a bare socket on purpose: a blocked local
|
||||
network connection surfaces here as a plain unreachable/timeout instead of a
|
||||
silent hang.
|
||||
*/
|
||||
enum Net {
|
||||
|
||||
enum Probe: Equatable {
|
||||
case open // something accepted the connection
|
||||
case refused // nothing listening, but the host answered
|
||||
case unreachable // no route — or macOS is withholding local network access
|
||||
case timedOut
|
||||
case failed(String)
|
||||
|
||||
var isReachableHost: Bool { self == .open || self == .refused }
|
||||
}
|
||||
|
||||
/// Probe host:port, calling back on the main queue exactly once.
|
||||
static func probe(host: String, port: UInt16, timeout: TimeInterval = 2.0,
|
||||
completion: @escaping (Probe) -> Void) {
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: port) else {
|
||||
return DispatchQueue.main.async { completion(.failed("invalid port")) }
|
||||
}
|
||||
let tcp = NWProtocolTCP.Options()
|
||||
tcp.connectionTimeout = max(1, Int(timeout))
|
||||
tcp.noDelay = true
|
||||
let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort,
|
||||
using: NWParameters(tls: nil, tcp: tcp))
|
||||
|
||||
var settled = false
|
||||
let finish: (Probe) -> Void = { result in
|
||||
guard !settled else { return }
|
||||
settled = true
|
||||
connection.cancel()
|
||||
DispatchQueue.main.async { completion(result) }
|
||||
}
|
||||
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
finish(.open)
|
||||
case .waiting(let error):
|
||||
// "waiting" means it would keep retrying — for a probe that is the answer.
|
||||
finish(map(error))
|
||||
case .failed(let error):
|
||||
finish(map(error))
|
||||
case .cancelled:
|
||||
finish(.failed("cancelled"))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: .global(qos: .utility))
|
||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + timeout) { finish(.timedOut) }
|
||||
}
|
||||
|
||||
/// Blocking form, for preflight on a background queue. Never call it on main.
|
||||
static func probeSync(host: String, port: UInt16, timeout: TimeInterval = 2.0) -> Probe {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var result: Probe = .timedOut
|
||||
probe(host: host, port: port, timeout: timeout) { outcome in
|
||||
result = outcome
|
||||
semaphore.signal()
|
||||
}
|
||||
_ = semaphore.wait(timeout: .now() + timeout + 1.0)
|
||||
return result
|
||||
}
|
||||
|
||||
private static func map(_ error: NWError) -> Probe {
|
||||
if case .posix(let code) = error {
|
||||
switch code {
|
||||
case .ECONNREFUSED: return .refused
|
||||
case .EHOSTUNREACH, .ENETUNREACH, .EHOSTDOWN, .ENETDOWN: return .unreachable
|
||||
case .ETIMEDOUT: return .timedOut
|
||||
default: return .failed(String(describing: code))
|
||||
}
|
||||
}
|
||||
return .failed(error.localizedDescription)
|
||||
}
|
||||
|
||||
/// Is this address on this building's network (so the Local Network
|
||||
/// permission applies) rather than out on the internet?
|
||||
static func isLocalAddress(_ host: String) -> Bool {
|
||||
let name = host.lowercased()
|
||||
if name == "localhost" || name.hasSuffix(".local") || name.hasSuffix(".lan") { return true }
|
||||
let parts = name.split(separator: ".").map(String.init)
|
||||
guard parts.count == 4, let a = Int(parts[0]), let b = Int(parts[1]),
|
||||
parts.allSatisfy({ Int($0) != nil }) else {
|
||||
// A bare hostname with no dots is a LAN name; anything else is the internet.
|
||||
return !name.contains(".")
|
||||
}
|
||||
if a == 10 || a == 127 { return true }
|
||||
if a == 192 && b == 168 { return true }
|
||||
if a == 172 && (16...31).contains(b) { return true }
|
||||
if a == 169 && b == 254 { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// PIDs listening on a TCP port, so a stray ProdDash can be handed over.
|
||||
static func listenerPIDs(port: Int) -> [pid_t] {
|
||||
let task = Process()
|
||||
task.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof")
|
||||
task.arguments = ["-t", "-iTCP:\(port)", "-sTCP:LISTEN"]
|
||||
let pipe = Pipe()
|
||||
task.standardOutput = pipe
|
||||
task.standardError = FileHandle.nullDevice
|
||||
do { try task.run() } catch { return [] }
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
task.waitUntilExit()
|
||||
return (String(data: data, encoding: .utf8) ?? "")
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.compactMap { pid_t($0.trimmingCharacters(in: .whitespaces)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Network
|
||||
import UserNotifications
|
||||
|
||||
/**
|
||||
The permissions ProdDash's modules need, and this app's job of holding them.
|
||||
|
||||
A module can say what it needs in its manifest, which is how this pane knows
|
||||
to mention the microphone at all:
|
||||
|
||||
"permissions": [
|
||||
{ "kind": "microphone", "reason": "Decoding LTC timecode from an audio input" }
|
||||
]
|
||||
|
||||
The shell ignores the key; only the launcher reads it. A bare
|
||||
`"permissions": ["microphone"]` works too, and a kind this launcher doesn't
|
||||
manage is still listed — better to name it than to pretend it isn't needed.
|
||||
*/
|
||||
enum PermissionKind: Hashable {
|
||||
case microphone
|
||||
case localNetwork
|
||||
case other(String)
|
||||
|
||||
init(raw: String) {
|
||||
switch raw.lowercased().replacingOccurrences(of: " ", with: "-")
|
||||
.replacingOccurrences(of: "_", with: "-") {
|
||||
case "microphone", "audio-input", "mic": self = .microphone
|
||||
case "local-network", "localnetwork", "lan": self = .localNetwork
|
||||
case let other: self = .other(other)
|
||||
}
|
||||
}
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .microphone: return "microphone"
|
||||
case .localNetwork: return "local-network"
|
||||
case .other(let raw): return raw
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .microphone: return "Microphone"
|
||||
case .localNetwork: return "Local Network"
|
||||
case .other(let raw): return raw.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
/// Every module that needs LTC needs the same thing; this is the standing
|
||||
/// explanation when a module doesn't give its own.
|
||||
var defaultReason: String {
|
||||
switch self {
|
||||
case .microphone:
|
||||
return "Reading an audio input on this machine."
|
||||
case .localNetwork:
|
||||
return "Reaching production gear on this network."
|
||||
case .other:
|
||||
return "Required by a module."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why one set of modules needs a permission. Modules that give the same
|
||||
/// reason are listed together, so three modules reaching the same network
|
||||
/// don't produce three near-identical lines.
|
||||
struct PermissionClaim: Identifiable, Equatable {
|
||||
let reason: String
|
||||
var modules: [String]
|
||||
var id: String { reason }
|
||||
|
||||
var askedBy: String { modules.joined(separator: ", ") }
|
||||
}
|
||||
|
||||
/// One permission, and everyone who asked for it.
|
||||
struct ModuleNeed: Identifiable, Equatable {
|
||||
let kind: PermissionKind
|
||||
var claims: [PermissionClaim] = []
|
||||
var id: String { kind.id }
|
||||
|
||||
var modules: [String] {
|
||||
var seen: [String] = []
|
||||
for claim in claims where !claim.modules.isEmpty {
|
||||
for module in claim.modules where !seen.contains(module) { seen.append(module) }
|
||||
}
|
||||
return seen
|
||||
}
|
||||
}
|
||||
|
||||
/// A configured upstream and whether this machine can reach it.
|
||||
struct HostCheck: Identifiable {
|
||||
let id = UUID()
|
||||
let module: String
|
||||
let host: String
|
||||
let port: Int
|
||||
var result: Net.Probe?
|
||||
|
||||
var label: String { "\(host):\(port)" }
|
||||
}
|
||||
|
||||
final class PermissionsModel: ObservableObject {
|
||||
|
||||
@Published private(set) var needs: [ModuleNeed] = []
|
||||
@Published private(set) var microphone: AVAuthorizationStatus = .notDetermined
|
||||
@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?
|
||||
|
||||
// MARK: - Status
|
||||
|
||||
func refresh(root: URL?, dataDir: URL) {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||
if status != microphone { microphone = status }
|
||||
Notifier.shared.refreshStatus { [weak self] status in
|
||||
if status != self?.notifications { self?.notifications = status }
|
||||
}
|
||||
let scanned = Self.scanModules(root: root, dataDir: dataDir)
|
||||
if scanned != needs { needs = scanned }
|
||||
if hostChecks.isEmpty {
|
||||
let hosts = Self.configuredLocalHosts(root: root, dataDir: dataDir)
|
||||
if !hosts.isEmpty { hostChecks = hosts }
|
||||
}
|
||||
}
|
||||
|
||||
func requestMicrophone() {
|
||||
guard microphone == .notDetermined else {
|
||||
return SystemSettings.open(SystemSettings.microphone)
|
||||
}
|
||||
AVCaptureDevice.requestAccess(for: .audio) { [weak self] _ in
|
||||
DispatchQueue.main.async {
|
||||
self?.microphone = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||
LogStore.shared.launcher("microphone access: \(self?.microphone.description ?? "?")")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestNotifications() {
|
||||
guard notifications == .notDetermined else {
|
||||
return SystemSettings.open(SystemSettings.notifications)
|
||||
}
|
||||
Notifier.shared.requestAuthorization { [weak self] _ in
|
||||
Notifier.shared.refreshStatus { status in self?.notifications = status }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Local network
|
||||
|
||||
/**
|
||||
macOS never reports whether local network access was granted, so this
|
||||
does the only thing that actually answers the question: touch the network
|
||||
the way the modules do — a Bonjour browse (which is what raises the
|
||||
prompt) and a connection attempt to each configured upstream — then say
|
||||
what the results mean.
|
||||
*/
|
||||
func checkLocalNetwork(root: URL?, dataDir: URL) {
|
||||
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()
|
||||
|
||||
guard !checks.isEmpty else {
|
||||
// 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."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let group = DispatchGroup()
|
||||
for (index, check) in checks.enumerated() {
|
||||
group.enter()
|
||||
Net.probe(host: check.host, port: UInt16(check.port), timeout: 3) { [weak self] result in
|
||||
self?.hostChecks[index].result = result
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
group.notify(queue: .main) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.checking = false
|
||||
self.stopBonjourBrowse()
|
||||
let results = self.hostChecks.compactMap(\.result)
|
||||
let answered = results.filter(\.isReachableHost).count
|
||||
if answered == results.count {
|
||||
self.localNetworkVerdict = "Local network access is working — 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."
|
||||
} 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."
|
||||
}
|
||||
LogStore.shared.launcher("local network check: \(self.localNetworkVerdict)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Browsing for Bonjour services is what makes macOS raise the local
|
||||
/// network prompt; the results themselves don't matter here.
|
||||
private func startBonjourBrowse() {
|
||||
stopBonjourBrowse()
|
||||
let browser = NWBrowser(for: .bonjour(type: "_http._tcp", domain: nil), using: .tcp)
|
||||
browser.start(queue: .global(qos: .utility))
|
||||
self.browser = browser
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 6) { [weak self] in self?.stopBonjourBrowse() }
|
||||
}
|
||||
|
||||
private func stopBonjourBrowse() {
|
||||
browser?.cancel()
|
||||
browser = nil
|
||||
}
|
||||
|
||||
// MARK: - Reading the modules
|
||||
|
||||
/// Manifests from both places the shell looks: the checkout and the
|
||||
/// modules installed from the admin page.
|
||||
private static func moduleManifests(root: URL?, dataDir: URL) -> [(id: String, name: String, json: [String: Any])] {
|
||||
var found: [String: (String, [String: Any])] = [:]
|
||||
for base in [root?.appendingPathComponent("modules"), dataDir.appendingPathComponent("modules")].compactMap({ $0 }) {
|
||||
let dirs = (try? FileManager.default.contentsOfDirectory(atPath: base.path)) ?? []
|
||||
for dir in dirs where !dir.hasPrefix(".") {
|
||||
let file = base.appendingPathComponent(dir).appendingPathComponent("module.json")
|
||||
guard let data = try? Data(contentsOf: file),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue }
|
||||
found[dir] = (json["name"] as? String ?? dir, json)
|
||||
}
|
||||
}
|
||||
return found.map { (id: $0.key, name: $0.value.0, json: $0.value.1) }
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
static func scanModules(root: URL?, dataDir: URL) -> [ModuleNeed] {
|
||||
var byKind: [PermissionKind: ModuleNeed] = [:]
|
||||
for module in moduleManifests(root: root, dataDir: dataDir) {
|
||||
guard let raw = module.json["permissions"] as? [Any] else { continue }
|
||||
for entry in raw {
|
||||
var kind: PermissionKind?
|
||||
var reason = ""
|
||||
if let name = entry as? String {
|
||||
kind = PermissionKind(raw: name)
|
||||
} else if let object = entry as? [String: Any], let name = object["kind"] as? String {
|
||||
kind = PermissionKind(raw: name)
|
||||
reason = (object["reason"] as? String ?? "").trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
guard let kind else { continue }
|
||||
var need = byKind[kind] ?? ModuleNeed(kind: kind)
|
||||
let text = reason.isEmpty ? kind.defaultReason : reason
|
||||
if let index = need.claims.firstIndex(where: { $0.reason == text }) {
|
||||
if !need.claims[index].modules.contains(module.name) {
|
||||
need.claims[index].modules.append(module.name)
|
||||
}
|
||||
} else {
|
||||
need.claims.append(PermissionClaim(reason: text, modules: [module.name]))
|
||||
}
|
||||
byKind[kind] = need
|
||||
}
|
||||
}
|
||||
// Stable order: the two the launcher manages first, then anything else.
|
||||
let order: [PermissionKind] = [.microphone, .localNetwork]
|
||||
return byKind.values.sorted {
|
||||
let a = order.firstIndex(of: $0.kind) ?? order.count
|
||||
let b = order.firstIndex(of: $1.kind) ?? order.count
|
||||
return a == b ? $0.kind.title < $1.kind.title : a < b
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
The upstreams the admin page has been pointed at, pulled out of the
|
||||
module config the server writes (modules.json). Endpoint settings are
|
||||
`{ host, port }` objects and URLs are strings — both shapes appear, and
|
||||
both are worth testing, so this walks the config rather than knowing any
|
||||
particular module's key names.
|
||||
*/
|
||||
static func configuredLocalHosts(root: URL?, dataDir: URL) -> [HostCheck] {
|
||||
guard let data = try? Data(contentsOf: dataDir.appendingPathComponent("modules.json")),
|
||||
let config = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [] }
|
||||
let names = Dictionary(uniqueKeysWithValues: moduleManifests(root: root, dataDir: dataDir).map { ($0.id, $0.name) })
|
||||
var checks: [HostCheck] = []
|
||||
var seen = Set<String>()
|
||||
|
||||
func consider(_ host: String, _ port: Int, module: String) {
|
||||
let host = host.trimmingCharacters(in: .whitespaces)
|
||||
guard !host.isEmpty, Net.isLocalAddress(host), host != "localhost", host != "127.0.0.1" else { return }
|
||||
let key = "\(host):\(port)"
|
||||
guard !seen.contains(key) else { return }
|
||||
seen.insert(key)
|
||||
checks.append(HostCheck(module: module, host: host, port: port))
|
||||
}
|
||||
|
||||
func walk(_ value: Any, module: String) {
|
||||
if let object = value as? [String: Any] {
|
||||
if let host = object["host"] as? String {
|
||||
consider(host, (object["port"] as? NSNumber)?.intValue ?? 80, module: module)
|
||||
}
|
||||
for nested in object.values { walk(nested, module: module) }
|
||||
} else if let list = value as? [Any] {
|
||||
for nested in list { walk(nested, module: module) }
|
||||
} else if let text = value as? String, text.contains("://"),
|
||||
let parts = URLComponents(string: text), let host = parts.host {
|
||||
consider(host, parts.port ?? (parts.scheme == "https" ? 443 : 80), module: module)
|
||||
}
|
||||
}
|
||||
|
||||
for (id, entry) in config {
|
||||
guard let entry = entry as? [String: Any], let moduleConfig = entry["config"] else { continue }
|
||||
walk(moduleConfig, module: names[id] ?? id)
|
||||
}
|
||||
return checks.sorted { $0.module < $1.module }
|
||||
}
|
||||
}
|
||||
|
||||
extension AVAuthorizationStatus {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .authorized: return "granted"
|
||||
case .denied: return "denied"
|
||||
case .restricted: return "restricted"
|
||||
case .notDetermined: return "not asked yet"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
enum ServerState: Equatable {
|
||||
case stopped
|
||||
case starting
|
||||
case running
|
||||
case stopping
|
||||
case failed(String)
|
||||
|
||||
var isBusy: Bool { self == .starting || self == .running || self == .stopping }
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .stopped: return "Stopped"
|
||||
case .starting: return "Starting…"
|
||||
case .running: return "Running"
|
||||
case .stopping: return "Stopping…"
|
||||
case .failed: return "Problem"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Starts, stops and watches over the ProdDash server.
|
||||
|
||||
The shell was built for this: it takes `PRODDASH_LAUNCHER=1` to mean "someone
|
||||
is supervising me", and then exits **75** instead of respawning itself when
|
||||
the admin page applies an update — so a 75 here is a restart request, not a
|
||||
fault. Anything else unexpected is a crash: it gets a backed-off restart, and
|
||||
a burst of fast crashes stops the cycle rather than looping forever.
|
||||
|
||||
Starting the server from this app is also what gives the modules their
|
||||
permissions. macOS hands a child process the *responsible process* of its
|
||||
parent, so node — and the `ltc-capture` tool the Timers module runs — inherit
|
||||
this app bundle's identity, and the microphone and local-network prompts are
|
||||
asked, and remembered, in ProdDash's name. A server started over SSH or from
|
||||
a bare launchd job has no such identity, which is why its LTC input is
|
||||
silently all zeros.
|
||||
*/
|
||||
final class ServerController: ObservableObject {
|
||||
|
||||
@Published private(set) var state: ServerState = .stopped
|
||||
/// One line under the status pill: why it stopped, what to fix.
|
||||
@Published private(set) var message = ""
|
||||
@Published private(set) var port: Int = ProdDash.defaultPort
|
||||
@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
|
||||
@Published private(set) var rootURL: URL?
|
||||
@Published private(set) var nodeURL: URL?
|
||||
@Published private(set) var nodeVersion = ""
|
||||
|
||||
/// Asked when the port is already taken: (port, looks like ProdDash, reply).
|
||||
var onPortBusy: ((Int, Bool, @escaping (Bool) -> Void) -> Void)?
|
||||
|
||||
private let settings: LauncherSettings
|
||||
private let log = LogStore.shared
|
||||
|
||||
private var child: ChildProcess?
|
||||
private var intentionalStop = false
|
||||
private var restartAfterExit = false
|
||||
private var startedAt: Date?
|
||||
private var crashTimes: [Date] = []
|
||||
private var pendingRestart: DispatchWorkItem?
|
||||
private var healthTimer: Timer?
|
||||
private var healthMisses = 0
|
||||
private var openedDashboardThisRun = false
|
||||
/// The most recent line that reads like a reason, shown with a failure.
|
||||
private var lastProblemLine = ""
|
||||
|
||||
init(settings: LauncherSettings) {
|
||||
self.settings = settings
|
||||
refreshEnvironment()
|
||||
}
|
||||
|
||||
// MARK: - Environment
|
||||
|
||||
/// Re-resolve the checkout, the node binary and the port. Cheap, and run
|
||||
/// again before every start so a folder fixed in Settings takes effect.
|
||||
func refreshEnvironment() {
|
||||
let root = ProdDash.findRoot(preferred: settings.rootPath)
|
||||
rootURL = root
|
||||
let node = ProdDash.findNode(root: root, preferred: settings.nodePath)
|
||||
if node?.path != nodeURL?.path {
|
||||
nodeURL = node
|
||||
nodeVersion = node.map(ProdDash.nodeVersion) ?? ""
|
||||
}
|
||||
if let root {
|
||||
let version = ProdDash.shellVersion(root: root)
|
||||
if !version.isEmpty { shellVersion = version }
|
||||
}
|
||||
if !state.isBusy {
|
||||
port = ProdDash.port(root: root, dataDir: dataDir)
|
||||
}
|
||||
}
|
||||
|
||||
var dataDir: URL { ProdDash.dataDir(root: rootURL) }
|
||||
var dashboardURL: URL { URL(string: "http://localhost:\(port)/")! }
|
||||
var adminURL: URL { URL(string: "http://localhost:\(port)/admin")! }
|
||||
|
||||
// MARK: - Start
|
||||
|
||||
func start() {
|
||||
guard !state.isBusy else { return }
|
||||
pendingRestart?.cancel()
|
||||
pendingRestart = nil
|
||||
refreshEnvironment()
|
||||
|
||||
guard let root = rootURL else {
|
||||
return fail("Can't find the ProdDash folder. Choose it under Settings.")
|
||||
}
|
||||
guard let node = nodeURL else {
|
||||
return fail("Node.js 18 or newer isn't installed. Install it from nodejs.org, or set the path under Settings.")
|
||||
}
|
||||
if ProdDash.nodeIsTooOld(nodeVersion) {
|
||||
return fail("This machine's node is \(nodeVersion) — ProdDash needs 18 or newer.")
|
||||
}
|
||||
|
||||
state = .starting
|
||||
message = ""
|
||||
localURL = ""
|
||||
networkURLs = []
|
||||
modulesSummary = ""
|
||||
healthy = false
|
||||
healthMisses = 0
|
||||
openedDashboardThisRun = false
|
||||
lastProblemLine = ""
|
||||
|
||||
let port = self.port
|
||||
// The port probe and the "is that ProdDash?" request both block; keep
|
||||
// them off the main queue so the window stays live.
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
let busy = Net.probeSync(host: "127.0.0.1", port: UInt16(port), timeout: 1.0) == .open
|
||||
let isProdDash = busy && Self.respondsAsProdDash(port: port)
|
||||
DispatchQueue.main.async {
|
||||
guard let self, self.state == .starting else { return }
|
||||
guard busy else { return self.spawn(root: root, node: node, port: port) }
|
||||
guard let ask = self.onPortBusy else {
|
||||
return self.fail("Port \(port) is already in use.")
|
||||
}
|
||||
ask(port, isProdDash) { takeOver in
|
||||
guard takeOver else {
|
||||
self.state = .stopped
|
||||
self.message = "Port \(port) is already in use."
|
||||
return
|
||||
}
|
||||
self.takeOverPort(port)
|
||||
self.spawn(root: root, node: node, port: port)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func spawn(root: URL, node: URL, port: Int) {
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["PRODDASH_LAUNCHER"] = "1" // "someone is supervising me" — see the note above
|
||||
env["PORT"] = String(port)
|
||||
// A GUI app inherits almost no PATH. Put node's own folder first, and
|
||||
// keep the usual system ones so the Timers module can still find swiftc
|
||||
// when it needs to build its capture tool.
|
||||
let base = env["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
env["PATH"] = node.deletingLastPathComponent().path + ":" + base + ":/usr/local/bin:/opt/homebrew/bin"
|
||||
|
||||
log.launcher("starting: \(node.path) server.js (in \(root.path), port \(port))")
|
||||
do {
|
||||
child = try ChildProcess.run(
|
||||
executable: node.path,
|
||||
arguments: ["server.js"],
|
||||
directory: root.path,
|
||||
environment: env,
|
||||
onLine: { [weak self] line, isError in self?.handleOutput(line, isError: isError) },
|
||||
onExit: { [weak self] code, bySignal in self?.handleExit(code: code, bySignal: bySignal) })
|
||||
intentionalStop = false
|
||||
startedAt = Date()
|
||||
startHealthPolling()
|
||||
} catch {
|
||||
fail(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand the port over from a ProdDash somebody started another way. The
|
||||
/// server we are about to start waits for the port to free up on its own.
|
||||
private func takeOverPort(_ port: Int) {
|
||||
let pids = Net.listenerPIDs(port: port)
|
||||
guard !pids.isEmpty else { return }
|
||||
log.launcher("taking port \(port) over from pid \(pids.map(String.init).joined(separator: ", "))")
|
||||
for pid in pids { kill(pid, SIGTERM) }
|
||||
Thread.sleep(forTimeInterval: 0.6)
|
||||
}
|
||||
|
||||
// MARK: - Stop / restart
|
||||
|
||||
func stop() {
|
||||
pendingRestart?.cancel()
|
||||
pendingRestart = nil
|
||||
guard let child else {
|
||||
state = .stopped
|
||||
return
|
||||
}
|
||||
intentionalStop = true
|
||||
state = .stopping
|
||||
message = ""
|
||||
log.launcher("stopping ProdDash")
|
||||
child.terminate()
|
||||
let pid = child.pid
|
||||
// The server closes in about a second and a half; this is the backstop.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
|
||||
guard let self, let current = self.child, current.pid == pid, current.isAlive else { return }
|
||||
self.log.launcher("ProdDash didn't stop on its own after 8s — forcing it")
|
||||
current.forceKill()
|
||||
}
|
||||
}
|
||||
|
||||
func restart() {
|
||||
guard child != nil else { return start() }
|
||||
restartAfterExit = true
|
||||
stop()
|
||||
}
|
||||
|
||||
func toggle() {
|
||||
switch state {
|
||||
case .running, .starting: stop()
|
||||
case .stopped, .failed: start()
|
||||
case .stopping: break
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleRestart(after seconds: TimeInterval) {
|
||||
let work = DispatchWorkItem { [weak self] in self?.start() }
|
||||
pendingRestart = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: work)
|
||||
}
|
||||
|
||||
private func fail(_ reason: String) {
|
||||
state = .failed(reason)
|
||||
message = reason
|
||||
log.launcher(reason)
|
||||
}
|
||||
|
||||
// MARK: - Child output
|
||||
|
||||
private func handleOutput(_ line: String, isError: Bool) {
|
||||
log.append(line, kind: isError ? .err : .out)
|
||||
if line.contains("cannot start") || line.contains("Error:") || line.contains("EADDRINUSE") {
|
||||
lastProblemLine = line.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
parseBanner(line)
|
||||
}
|
||||
|
||||
/**
|
||||
The server prints a short banner once it is listening:
|
||||
|
||||
ProdDash 1.3.0
|
||||
Local : http://localhost:24500
|
||||
Network : http://10.0.1.42:24500
|
||||
Modules : clock, prodcom-transcript, …
|
||||
Settings : /Users/booth/Library/Application Support/ProdDash
|
||||
|
||||
Reading it is how the launcher learns it is really up (before the health
|
||||
poll gets there) and what to show in the window.
|
||||
*/
|
||||
private func parseBanner(_ line: String) {
|
||||
let text = line.trimmingCharacters(in: .whitespaces)
|
||||
if text.hasPrefix("ProdDash "), text.dropFirst(9).first?.isNumber == true {
|
||||
shellVersion = String(text.dropFirst(9)).trimmingCharacters(in: .whitespaces)
|
||||
if state == .starting {
|
||||
state = .running
|
||||
message = ""
|
||||
log.launcher("ProdDash \(shellVersion) is up on port \(port)")
|
||||
if settings.openDashboardOnStart { openDashboard() }
|
||||
openedDashboardThisRun = true
|
||||
}
|
||||
return
|
||||
}
|
||||
guard let colon = text.firstIndex(of: ":") else { return }
|
||||
let field = text[text.startIndex..<colon].trimmingCharacters(in: .whitespaces)
|
||||
let value = text[text.index(after: colon)...].trimmingCharacters(in: .whitespaces)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Exit
|
||||
|
||||
private func handleExit(code: Int32, bySignal: Bool) {
|
||||
stopHealthPolling()
|
||||
child = nil
|
||||
healthy = false
|
||||
let ranFor = startedAt.map { Date().timeIntervalSince($0) } ?? 0
|
||||
if ranFor > 60 { crashTimes.removeAll() } // a long healthy run clears the slate
|
||||
|
||||
if restartAfterExit {
|
||||
restartAfterExit = false
|
||||
state = .stopped
|
||||
start()
|
||||
return
|
||||
}
|
||||
if intentionalStop {
|
||||
state = .stopped
|
||||
message = "Stopped."
|
||||
log.launcher("ProdDash stopped")
|
||||
return
|
||||
}
|
||||
// 75 is the shell's "start me again" — the admin page applied an update.
|
||||
if !bySignal && code == 75 {
|
||||
log.launcher("ProdDash applied an update and asked to be restarted")
|
||||
state = .stopped
|
||||
message = "Restarting to apply an update…"
|
||||
start()
|
||||
return
|
||||
}
|
||||
|
||||
let how = bySignal ? "was killed by signal \(code)" : "exited with code \(code)"
|
||||
let detail = lastProblemLine.isEmpty ? "" : " — \(lastProblemLine)"
|
||||
crashTimes.append(Date())
|
||||
crashTimes = crashTimes.filter { Date().timeIntervalSince($0) < 120 }
|
||||
|
||||
guard settings.restartOnCrash, crashTimes.count < 4 else {
|
||||
let why = settings.restartOnCrash
|
||||
? "ProdDash stopped \(crashTimes.count) times in two minutes — leaving it stopped."
|
||||
: "ProdDash \(how)."
|
||||
let last = lastProblemLine.isEmpty ? "" : " Last message: \(lastProblemLine)"
|
||||
fail(why + last)
|
||||
Notifier.shared.post(title: "ProdDash stopped", body: why, if: settings.notifyOnProblem)
|
||||
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)
|
||||
scheduleRestart(after: delay)
|
||||
}
|
||||
|
||||
// MARK: - Health
|
||||
|
||||
private func startHealthPolling() {
|
||||
healthTimer?.invalidate()
|
||||
let timer = Timer(timeInterval: 3, repeats: true) { [weak self] _ in self?.pollHealth() }
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
healthTimer = timer
|
||||
}
|
||||
|
||||
private func stopHealthPolling() {
|
||||
healthTimer?.invalidate()
|
||||
healthTimer = nil
|
||||
}
|
||||
|
||||
private func pollHealth() {
|
||||
let port = self.port
|
||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||
let ok = Self.respondsAsProdDash(port: port)
|
||||
DispatchQueue.main.async {
|
||||
guard let self, self.child != nil else { return }
|
||||
if ok {
|
||||
self.healthMisses = 0
|
||||
self.healthy = true
|
||||
if self.state == .starting {
|
||||
self.state = .running
|
||||
if self.settings.openDashboardOnStart, !self.openedDashboardThisRun {
|
||||
self.openedDashboardThisRun = true
|
||||
self.openDashboard()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.healthMisses += 1
|
||||
if self.healthMisses >= 2 { self.healthy = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Does something on this port answer ProdDash's own unauthenticated
|
||||
/// module list? Blocking — background queues only.
|
||||
static func respondsAsProdDash(port: Int) -> Bool {
|
||||
guard let url = URL(string: "http://127.0.0.1:\(port)/api/modules") else { return false }
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 2
|
||||
request.httpMethod = "GET"
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var ok = false
|
||||
URLSession.shared.dataTask(with: request) { data, response, _ in
|
||||
defer { semaphore.signal() }
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200,
|
||||
let data, let body = String(data: data, encoding: .utf8) else { return }
|
||||
ok = body.contains("\"modules\"")
|
||||
}.resume()
|
||||
_ = semaphore.wait(timeout: .now() + 3)
|
||||
return ok
|
||||
}
|
||||
|
||||
// MARK: - Settings ProdDash owns
|
||||
|
||||
/// Write the port where ProdDash reads it, then restart if it is running.
|
||||
func applyPort(_ newPort: Int) {
|
||||
guard ProdDash.isValidPort(newPort) else {
|
||||
message = "\(newPort) isn't a usable port number."
|
||||
return
|
||||
}
|
||||
guard newPort != port else { return }
|
||||
do {
|
||||
try ProdDash.setPort(newPort, dataDir: dataDir)
|
||||
port = newPort
|
||||
log.launcher("port set to \(newPort)")
|
||||
if child != nil { restart() }
|
||||
} catch {
|
||||
message = "Couldn't save the port: \(error.localizedDescription)"
|
||||
log.launcher(message)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Opening things
|
||||
|
||||
func openDashboard() { NSWorkspace.shared.open(dashboardURL) }
|
||||
func openAdmin() { NSWorkspace.shared.open(adminURL) }
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AppKit
|
||||
|
||||
// ProdDash Launcher — a menu-bar app, so no Dock icon and no app menu
|
||||
// (LSUIElement in Info.plist says the same thing to Launch Services).
|
||||
let delegate = AppDelegate()
|
||||
let application = NSApplication.shared
|
||||
application.delegate = delegate
|
||||
application.setActivationPolicy(.accessory)
|
||||
application.run()
|
||||
Reference in New Issue
Block a user