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>
226 lines
10 KiB
Swift
226 lines
10 KiB
Swift
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
|
|
}
|
|
}
|