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>
126 lines
5.1 KiB
Swift
126 lines
5.1 KiB
Swift
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)) }
|
|
}
|
|
}
|