diff --git a/.gitignore b/.gitignore index 542e8b2..450fd9c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ config/layouts/ # compiled per-machine, source sits next to it modules/propresenter-timers/ltc-capture + +# the launcher's build output (built per machine from launcher/macos/build.sh) +launcher/macos/build/ diff --git a/CLAUDE.md b/CLAUDE.md index b5670ff..ca9a420 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,11 +3,14 @@ Modular production dashboard web app for Waters Church, developed in this repo. The app lives at the repo root and is built: zero-dependency Node server (`server.js`, Node 18+), dashboard shell client in `public/`, admin page at -`/admin`, modules in `modules/`. Runtime state (module config, named layouts) -is written to a per-machine data directory outside the checkout -(`PRODDASH_DATA_DIR` overrides; see README "Where settings are kept") — never -add code that writes runtime state into the repo folder. [SPEC.md](SPEC.md) was the authority for the -initial build; [README.md](README.md) has run/configure/use instructions. +`/admin`, modules in `modules/`, and a native macOS menu-bar launcher in +`launcher/macos/` (Swift, no dependencies — it supervises the server and owns +the macOS permissions modules declare; see its README). Runtime state (module +config, named layouts) is written to a per-machine data directory outside the +checkout (`PRODDASH_DATA_DIR` overrides; see README "Where settings are +kept") — never add code that writes runtime state into the repo folder. +[SPEC.md](SPEC.md) was the authority for the initial build; +[README.md](README.md) has run/configure/use instructions. ## Building a new module — read docs/MODULE-GUIDE.md first diff --git a/README.md b/README.md index 6e138b5..16261e6 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,20 @@ and a clear degraded state, then recover on their own. Requires [Node.js](https://nodejs.org) 18 or newer. +On a Mac, run it from the **menu-bar launcher** — see +[launcher/macos](launcher/macos). It starts and stops the server, can open at +login, owns the port, and holds the permissions the modules need: the +microphone the LTC listener reads, and local network access for ProdCom and +ProPresenter. macOS grants those to the app that owns the process, so a server +started any other way — over SSH, from a launchd job — simply doesn't have +them, and an LTC input goes silently to zeroes. Build it once: + +```bash +cd launcher/macos && ./build.sh --install +``` + +Otherwise, from the app folder: + ```bash node server.js ``` @@ -73,7 +87,7 @@ with this machine's shell settings — the same keys as the checked-in | Key | Meaning | | --- | --- | -| `port` | Port ProdDash serves on (default `24500`) | +| `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. | | `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. | Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index cba9f14..1f0e621 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -66,6 +66,9 @@ the app); its settings are kept for a later reinstall. "version": "1.0.0", "proddash": ">=1.1.0", "description": "One line shown in the Add-tile picker and the admin page", + "permissions": [ + { "kind": "microphone", "reason": "Why this module needs it, in one line" } + ], "client": "client.js", "server": "server.js", "style": "style.css", @@ -88,6 +91,7 @@ the app); its settings are kept for a later reinstall. | `version` | yes | Your module's version (`x.y.z`). Bump it on every change — the admin page offers updates when the repo's copy is newer than the installed one. | | `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. | | `description` | no | Shown in the admin page / picker. | +| `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. | | `client` | yes | Client entry file, loaded as an ES module. | | `server` | no | Server entry file (CommonJS), loaded with `require()`. | | `style` | no | A stylesheet the shell injects once per module. | @@ -207,6 +211,38 @@ Admin config lives in `modules.json` inside the server's data directory survives updates of the app folder). Per-tile settings live inside each browser's layout. +### Permissions macOS has to grant + +Some things a module does need the operating system's permission, not just +configuration: reading an audio input, reaching gear on the local network. +macOS grants those to the **application that owns the process**, which for +ProdDash is the macOS launcher (`launcher/macos`) — the server is its child, +so the prompts are asked, and remembered, in ProdDash's name. A server +started over SSH or from a bare launchd job has no such owner, and the +permission is silently absent rather than refused: an LTC input reads as +endless zeroes, a local host simply never answers. + +Declare what you need, and why, so the launcher's Permissions tab can list +it and ask for it before a service rather than during one: + +```json +"permissions": [ + { "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" }, + { "kind": "local-network", "reason": "Reaching ProPresenter on this network" } +] +``` + +| `kind` | When to declare it | +| --- | --- | +| `microphone` | The module reads an audio input (`ltc-capture` and anything like it). | +| `local-network` | The module talks to gear on this building's network. Not needed for services out on the internet — Planning Center, say. | + +`reason` is one line, shown to the operator as-is; modules giving the same +reason are listed together. A bare `"permissions": ["microphone"]` works when +the standing explanation is enough. A `kind` the launcher doesn't manage is +still listed, so nothing goes unmentioned — the shell itself ignores the key +entirely, and other platforms ignore it too. + ## Client entry (`client.js`) An ES module whose **default export is a factory**. The shell calls it once diff --git a/launcher/macos/README.md b/launcher/macos/README.md new file mode 100644 index 0000000..72cec8c --- /dev/null +++ b/launcher/macos/README.md @@ -0,0 +1,127 @@ +# ProdDash Launcher (macOS) + +A menu-bar app that starts, stops and watches over the ProdDash server on a +Mac — and, just as importantly, **owns the permissions its modules need**. + +It is a native Swift app: about 1 MB, no dependencies, nothing to install but +the app itself. Build it with `./build.sh`. + +## Why a launcher, and not just `Start ProdDash.command` + +Two reasons, and the second is the one that bites. + +**Supervision.** The server is started as a child process, its output is kept +in a log, and it is brought back if it stops on its own. The shell was built +for this: with `PRODDASH_LAUNCHER=1` in its environment it exits **75** rather +than respawning itself when the admin page applies an update, which the +launcher reads as "start me again" instead of as a fault. A crash gets a +backed-off restart (1s, 2s, 5s, 10s); four crashes in two minutes stops the +cycle, so a broken install doesn't spin forever. + +**Permissions.** macOS grants privacy permissions to the *application* that +owns a process, and a child inherits its parent's identity. Start ProdDash +from here and node — plus the Timers module's `ltc-capture` tool — belong to +this app bundle, so the microphone and local-network prompts are asked, and +remembered, as **ProdDash**. Start it over SSH or from a bare launchd job and +there is no owner to ask: the permission is silently absent rather than +refused, and an LTC input reads as endless zeroes with no error anywhere. +That is the failure `modules/propresenter-timers/ltc-listener.js` warns about, +and this is the fix. + +## Build + +```bash +cd launcher/macos +./build.sh # build/ProdDash.app, universal (Apple Silicon + Intel) +./build.sh --install # …and copy it to /Applications +./build.sh --run # …and open it +./build.sh --arch native # this Mac's architecture only — faster while iterating +``` + +Needs the Xcode command line tools (`xcode-select --install`) and macOS 13 or +newer. The app icon is built from `public/icons/icon-512.png`, so ProdDash's +own icon is the app's icon. + +The build is signed ad-hoc, which is all a local tool needs — but the +signature changes on every rebuild, so macOS may treat a rebuilt app as a new +one and ask for the microphone again. Build once, install it, and leave it. + +## Using it + +The menu-bar icon is ProdDash's 2×2 tile mark; the accent tile is lit green +while the server runs, amber mid-move, red when something needs a person, and +hollow when it is stopped. Its menu has the whole app in it: + +- **Open Dashboard** / **Open Admin** — the running server, in a browser. +- **Start / Stop / Restart ProdDash**. +- **Launcher…** — the window below. +- **Open at Login** — a login item, through the modern `SMAppService` API. +- **Quit ProdDash** — stops the server too, and asks first while it is running. + +The window has the status, both addresses the dashboard answers on, and three +tabs: + +- **Log** — everything the server prints, plus what the launcher did and why. + Also written to `~/Library/Logs/ProdDash/proddash-.log`, kept a week. +- **Settings** — the port, and how it starts (at login, server on open, + restart on crash, open a browser, show this window). It also shows the + ProdDash folder, the node binary in use and where settings are kept. +- **Permissions** — what the installed modules declare they need, who needs + it, and what macOS currently allows. See below. + +### Port + +The port belongs to ProdDash, not to the launcher, so it is written to +`proddash.json` in the data directory — the file the server already reads. +Set it here and ProdDash keeps that port however it is started next. Changing +it while the server runs restarts it. + +### Permissions + +The pane lists what the installed modules declare in their manifests +(`"permissions": [ … ]` — see [docs/MODULE-GUIDE.md](../../docs/MODULE-GUIDE.md)), +with the module's own reason: + +- **Microphone** — asked for directly, with the status macOS reports. +- **Local Network** — macOS has no API that reports whether this was granted, + so **Check Access** asks the network instead: it browses for Bonjour + services (which is what raises the prompt) and tries every upstream the + admin page is pointed at, then says what the result means. Nothing + answering, when the gear is on, means macOS is withholding access. +- **Notifications** — the launcher's own, for a server that stops while + nobody is watching. + +On first run the launcher asks for what the modules declare, while someone is +still at the keyboard, rather than halfway through a service. + +## Details worth knowing + +- **One instance.** Opening a second copy raises the first one's window. +- **Port already in use.** If something already holds the port, the launcher + asks before doing anything. When that something answers like ProdDash — an + SSH-started copy, say, with no microphone access — it offers to take the + port over. +- **Stopping is thorough.** 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 until someone found it in Activity + Monitor. +- **Signals.** `killall ProdDash`, a logout or a shutdown stops the server + first rather than leaving node running. +- **Where the settings live.** ProdDash's port is in the data directory's + `proddash.json`; everything else (auto-start, restart-on-crash, the paths) + is the launcher's own, in its preferences. +- **Finding things.** The ProdDash folder is the checkout the app was built + inside, or `~/Apps/ProdDash` and the other usual spots, or whatever you + pick under Settings. Node is the checkout's bundled `runtime/bin/node` if + there is one, then Homebrew, `/usr/local`, nvm, Volta, and finally whatever + a login shell finds. + +## Troubleshooting + +| What you see | What it means | +| --- | --- | +| "Can't find the ProdDash folder" | Point it at the folder with `server.js` in it, under Settings. | +| "Node.js 18 or newer isn't installed" | Install Node, or set the path under Settings. A GUI app inherits almost no `PATH`, so a node in an unusual place has to be named. | +| "ProdDash stopped 4 times in two minutes" | The server is failing on startup — the Log tab has its own error. | +| LTC shows no signal | Permissions tab → Microphone. If it says granted and the input is still silent, the server may be an older copy started outside the launcher — Stop, then Start. | +| Nothing on the network answers | Permissions tab → **Check Access**. | diff --git a/launcher/macos/Resources/Info.plist b/launcher/macos/Resources/Info.plist new file mode 100644 index 0000000..97d105d --- /dev/null +++ b/launcher/macos/Resources/Info.plist @@ -0,0 +1,41 @@ + + + + + CFBundleName + ProdDash + CFBundleDisplayName + ProdDash + CFBundleExecutable + ProdDash + CFBundleIdentifier + org.waterschurch.proddash + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1.0.0 + CFBundleIconFile + AppIcon + LSApplicationCategoryType + public.app-category.utilities + LSMinimumSystemVersion + 13.0 + + LSUIElement + + NSHighResolutionCapable + + + NSMicrophoneUsageDescription + ProdDash reads an audio input on this Mac to decode SMPTE LTC timecode for the Timers module. + NSLocalNetworkUsageDescription + ProdDash connects to production gear on this network — ProdCom, ProPresenter and anything else its modules are pointed at. + NSBonjourServices + + _http._tcp + + + diff --git a/launcher/macos/Sources/AppDelegate.swift b/launcher/macos/Sources/AppDelegate.swift new file mode 100644 index 0000000..db4db69 --- /dev/null +++ b/launcher/macos/Sources/AppDelegate.swift @@ -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() + 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() + } + } +} diff --git a/launcher/macos/Sources/ChildProcess.swift b/launcher/macos/Sources/ChildProcess.swift new file mode 100644 index 0000000..2447e5f --- /dev/null +++ b/launcher/macos/Sources/ChildProcess.swift @@ -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?] = ([executable] + arguments).map { strdup($0) } + [nil] + let envp: [UnsafeMutablePointer?] = 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.. 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.. 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) + } + } + } +} diff --git a/launcher/macos/Sources/LauncherWindow.swift b/launcher/macos/Sources/LauncherWindow.swift new file mode 100644 index 0000000..c851218 --- /dev/null +++ b/launcher/macos/Sources/LauncherWindow.swift @@ -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) + } +} diff --git a/launcher/macos/Sources/LogStore.swift b/launcher/macos/Sources/LogStore.swift new file mode 100644 index 0000000..8dab0e7 --- /dev/null +++ b/launcher/macos/Sources/LogStore.swift @@ -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 + }() +} diff --git a/launcher/macos/Sources/Net.swift b/launcher/macos/Sources/Net.swift new file mode 100644 index 0000000..c580938 --- /dev/null +++ b/launcher/macos/Sources/Net.swift @@ -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)) } + } +} diff --git a/launcher/macos/Sources/Permissions.swift b/launcher/macos/Sources/Permissions.swift new file mode 100644 index 0000000..411b473 --- /dev/null +++ b/launcher/macos/Sources/Permissions.swift @@ -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() + + 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" + } + } +} diff --git a/launcher/macos/Sources/PermissionsPane.swift b/launcher/macos/Sources/PermissionsPane.swift new file mode 100644 index 0000000..851c770 --- /dev/null +++ b/launcher/macos/Sources/PermissionsPane.swift @@ -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) + } + } + } + } +} diff --git a/launcher/macos/Sources/ServerController.swift b/launcher/macos/Sources/ServerController.swift new file mode 100644 index 0000000..b8f6421 --- /dev/null +++ b/launcher/macos/Sources/ServerController.swift @@ -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.. 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) } +} diff --git a/launcher/macos/Sources/StatusIcon.swift b/launcher/macos/Sources/StatusIcon.swift new file mode 100644 index 0000000..bc81312 --- /dev/null +++ b/launcher/macos/Sources/StatusIcon.swift @@ -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 + } +} diff --git a/launcher/macos/Sources/SystemIntegration.swift b/launcher/macos/Sources/SystemIntegration.swift new file mode 100644 index 0000000..d970f02 --- /dev/null +++ b/launcher/macos/Sources/SystemIntegration.swift @@ -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" +} diff --git a/launcher/macos/Sources/main.swift b/launcher/macos/Sources/main.swift new file mode 100644 index 0000000..ba0f4a3 --- /dev/null +++ b/launcher/macos/Sources/main.swift @@ -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() diff --git a/launcher/macos/build.sh b/launcher/macos/build.sh new file mode 100755 index 0000000..1019e1a --- /dev/null +++ b/launcher/macos/build.sh @@ -0,0 +1,114 @@ +#!/bin/zsh +# Build ProdDash.app — the menu-bar launcher. +# +# ./build.sh build into launcher/macos/build/ProdDash.app +# ./build.sh --install …and copy it to /Applications +# ./build.sh --run …and launch it when it's built +# ./build.sh --arch native build for this Mac only (faster; default is universal) +# +# Needs the Xcode command line tools (swiftc). No other dependencies — the app +# is Swift and system frameworks, nothing to install and nothing to vendor. +set -e -u +cd "$(dirname "$0")" + +APP_NAME="ProdDash" +BUNDLE_ID="org.waterschurch.proddash" +OUT_DIR="build" +APP="$OUT_DIR/$APP_NAME.app" +ARCHS=(arm64 x86_64) +INSTALL=0 +RUN=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --install) INSTALL=1 ;; + --run) RUN=1 ;; + --arch) shift; [[ "${1:-}" == "native" ]] && ARCHS=($(uname -m)) || ARCHS=("${1:-arm64}") ;; + -h|--help) sed -n '2,10p' "$0"; exit 0 ;; + *) echo "build.sh: unknown option $1" >&2; exit 2 ;; + esac + shift +done + +if ! command -v swiftc >/dev/null; then + echo "build.sh: swiftc not found — install the Xcode command line tools:" >&2 + echo " xcode-select --install" >&2 + exit 1 +fi + +VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Resources/Info.plist) +echo "ProdDash Launcher $VERSION — building for ${ARCHS[*]}" + +rm -rf "$OUT_DIR" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$OUT_DIR/obj" + +# One binary per architecture, then lipo them together, so the same .app runs +# on the booth's Apple Silicon and on any Intel Mac still in service. +SLICES=() +for arch in "${ARCHS[@]}"; do + echo " compiling $arch" + swiftc -O -whole-module-optimization \ + -target "$arch-apple-macos13.0" \ + -module-name ProdDashLauncher \ + -o "$OUT_DIR/obj/$APP_NAME-$arch" \ + Sources/*.swift + SLICES+=("$OUT_DIR/obj/$APP_NAME-$arch") +done + +if [[ ${#SLICES[@]} -gt 1 ]]; then + lipo -create "${SLICES[@]}" -output "$APP/Contents/MacOS/$APP_NAME" +else + cp "${SLICES[1]}" "$APP/Contents/MacOS/$APP_NAME" +fi +chmod +x "$APP/Contents/MacOS/$APP_NAME" + +cp Resources/Info.plist "$APP/Contents/Info.plist" +printf 'APPL????' > "$APP/Contents/PkgInfo" + +# App icon: ProdDash's own, straight from the web app's icons. +SOURCE_ICON="../../public/icons/icon-512.png" +if [[ -f "$SOURCE_ICON" ]]; then + echo " building the icon" + ICONSET="$OUT_DIR/AppIcon.iconset" + mkdir -p "$ICONSET" + for size in 16 32 64 128 256 512 1024; do + sips -z $size $size "$SOURCE_ICON" --out "$ICONSET/icon_${size}x${size}.png" >/dev/null 2>&1 + done + # iconutil wants Apple's names, including the @2x pairs. + mv "$ICONSET/icon_32x32.png" "$ICONSET/icon_16x16@2x.png" + cp "$ICONSET/icon_64x64.png" "$ICONSET/icon_32x32@2x.png" + mv "$ICONSET/icon_64x64.png" "$ICONSET/icon_32x32.png" + cp "$ICONSET/icon_256x256.png" "$ICONSET/icon_128x128@2x.png" + cp "$ICONSET/icon_512x512.png" "$ICONSET/icon_256x256@2x.png" + mv "$ICONSET/icon_1024x1024.png" "$ICONSET/icon_512x512@2x.png" + iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/AppIcon.icns" + rm -rf "$ICONSET" +else + echo " (no $SOURCE_ICON — the app will use the generic icon)" +fi + +rm -rf "$OUT_DIR/obj" + +# Ad-hoc signature: enough for macOS to run it and to hang privacy permissions +# off it. Note that the signature changes on every rebuild, so macOS may treat +# a rebuilt app as a new one and ask for the microphone again. +codesign --force --sign - --identifier "$BUNDLE_ID" "$APP" >/dev/null 2>&1 \ + && echo " signed (ad-hoc)" \ + || echo " warning: could not sign the app — permissions may not stick" + +echo "built $APP" + +if [[ $INSTALL -eq 1 ]]; then + DEST="/Applications/$APP_NAME.app" + # A running copy holds its own bundle open; stop it before replacing it. + osascript -e "tell application \"$APP_NAME\" to quit" >/dev/null 2>&1 || true + sleep 1 + rm -rf "$DEST" + cp -R "$APP" "$DEST" + echo "installed $DEST" + APP="$DEST" +fi + +if [[ $RUN -eq 1 ]]; then + open "$APP" +fi diff --git a/modules/prodcom-transcript/module.json b/modules/prodcom-transcript/module.json index 390696c..8de270a 100644 --- a/modules/prodcom-transcript/module.json +++ b/modules/prodcom-transcript/module.json @@ -1,9 +1,12 @@ { "id": "prodcom-transcript", "name": "ProdCom", - "version": "1.2.0", + "version": "1.2.1", "proddash": ">=1.1.0", "description": "Live ProdCom transcript stream", + "permissions": [ + { "kind": "local-network", "reason": "Reaching the ProdCom API on this network" } + ], "client": "client.js", "server": "server.js", "style": "style.css", diff --git a/modules/propresenter-now-next/module.json b/modules/propresenter-now-next/module.json index e567176..fe25d0d 100644 --- a/modules/propresenter-now-next/module.json +++ b/modules/propresenter-now-next/module.json @@ -1,9 +1,12 @@ { "id": "propresenter-now-next", "name": "ProPresenter", - "version": "1.0.0", + "version": "1.0.1", "proddash": ">=1.1.0", "description": "What is live in ProPresenter and what comes next", + "permissions": [ + { "kind": "local-network", "reason": "Reaching ProPresenter on this network" } + ], "client": "client.js", "server": "server.js", "style": "style.css", diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index 4ca1bd3..c6f56b1 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -1,9 +1,13 @@ { "id": "propresenter-timers", "name": "Timers", - "version": "1.2.0", + "version": "1.2.1", "proddash": ">=1.2.0", "description": "Live ProPresenter timers and LTC timecode", + "permissions": [ + { "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" }, + { "kind": "local-network", "reason": "Reaching ProPresenter on this network" } + ], "client": "client.js", "server": "server.js", "style": "style.css",