From 1dbd55aa243ecdd9f3025c51c6c2ee84d835a265 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Fri, 11 Sep 2026 10:27:08 -0400 Subject: [PATCH 1/3] macOS menu-bar launcher: run ProdDash, and own its permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A native Swift app (launcher/macos, ~2 MB, no dependencies) that starts, stops and watches over the server from the menu bar — and holds the macOS permissions its modules need. The permissions are the point as much as the buttons are. macOS grants privacy permissions to the application that owns a process, and a child inherits its parent's identity, so with the server started from here node — and the Timers module's ltc-capture — belong to this app bundle and the microphone and local-network prompts are asked, and remembered, as ProdDash. Started over SSH or from a launchd job there is nobody to ask: the permission is silently absent rather than refused, which is the "endless zeroes" failure ltc-listener.js warns about. The shell was already built for a supervisor: with PRODDASH_LAUNCHER=1 it exits 75 instead of respawning itself when the admin page applies an update, which the launcher reads as a restart request rather than a fault. Crashes get a backed-off restart (1s, 2s, 5s, 10s) and four in two minutes stops the cycle. The server is spawned into its own process group, so stopping it also stops anything it started — an orphaned ltc-capture would otherwise hold an audio device indefinitely — and signals, a logout and Quit all stop it first. - Menu bar: ProdDash's own 2x2 tile mark, its accent tile lit green while serving, amber mid-move, red when something needs a person. Start, stop, restart, open the dashboard or admin, open at login, quit. - Window: status and both addresses, plus Log (also written to ~/Library/Logs/ProdDash, kept a week), Settings, and Permissions. - The port is written to the data directory's proddash.json — ProdDash's own file — so the port set here holds however it is next started. Something already on the port is reported, and offered as a takeover when it answers like a ProdDash started another way. - Modules declare what they need in their manifests ("permissions": [...], documented in docs/MODULE-GUIDE.md and ignored by the shell); the launcher lists exactly that, with each module's reason, asks for the microphone, and — since macOS reports nothing about local network access — offers a check that browses for services and tries every configured upstream. Timers, ProdCom and ProPresenter modules declare their permissions (patch bumps). Build with launcher/macos/build.sh; output is gitignored. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 + CLAUDE.md | 13 +- README.md | 16 +- docs/MODULE-GUIDE.md | 36 ++ launcher/macos/README.md | 127 ++++++ launcher/macos/Resources/Info.plist | 41 ++ launcher/macos/Sources/AppDelegate.swift | 336 ++++++++++++++ launcher/macos/Sources/ChildProcess.swift | 185 ++++++++ launcher/macos/Sources/LauncherConfig.swift | 225 +++++++++ launcher/macos/Sources/LauncherSettings.swift | 50 ++ launcher/macos/Sources/LauncherView.swift | 395 ++++++++++++++++ launcher/macos/Sources/LauncherWindow.swift | 26 ++ launcher/macos/Sources/LogStore.swift | 118 +++++ launcher/macos/Sources/Net.swift | 125 +++++ launcher/macos/Sources/Permissions.swift | 329 ++++++++++++++ launcher/macos/Sources/PermissionsPane.swift | 234 ++++++++++ launcher/macos/Sources/ServerController.swift | 427 ++++++++++++++++++ launcher/macos/Sources/StatusIcon.swift | 67 +++ .../macos/Sources/SystemIntegration.swift | 95 ++++ launcher/macos/Sources/main.swift | 9 + launcher/macos/build.sh | 114 +++++ modules/prodcom-transcript/module.json | 5 +- modules/propresenter-now-next/module.json | 5 +- modules/propresenter-timers/module.json | 6 +- 24 files changed, 2978 insertions(+), 9 deletions(-) create mode 100644 launcher/macos/README.md create mode 100644 launcher/macos/Resources/Info.plist create mode 100644 launcher/macos/Sources/AppDelegate.swift create mode 100644 launcher/macos/Sources/ChildProcess.swift create mode 100644 launcher/macos/Sources/LauncherConfig.swift create mode 100644 launcher/macos/Sources/LauncherSettings.swift create mode 100644 launcher/macos/Sources/LauncherView.swift create mode 100644 launcher/macos/Sources/LauncherWindow.swift create mode 100644 launcher/macos/Sources/LogStore.swift create mode 100644 launcher/macos/Sources/Net.swift create mode 100644 launcher/macos/Sources/Permissions.swift create mode 100644 launcher/macos/Sources/PermissionsPane.swift create mode 100644 launcher/macos/Sources/ServerController.swift create mode 100644 launcher/macos/Sources/StatusIcon.swift create mode 100644 launcher/macos/Sources/SystemIntegration.swift create mode 100644 launcher/macos/Sources/main.swift create mode 100755 launcher/macos/build.sh 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", From e1bceb3e604090c0ccd986744176b0f1d2f2c032 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Fri, 11 Sep 2026 11:10:22 -0400 Subject: [PATCH 2/3] Launcher: strip the window back to what's worth reading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pass for less prose and fewer boxes. - Header shows the version and node; the port is already in the addresses below it, and the module list belonged in the log. - Settings: "Network" over the port (no explanation under it), "Startup" instead of "Starting up", and no node chooser — the path stays visible, since which node runs is the first thing worth knowing when it won't. - Permissions: one line per permission — a status dot, its name, and the one thing you can do about it. The standing explanation is gone and the modules' own reasons moved to each row's tooltip. Local network results still appear under their row after a check, with a short line and a link only when nothing answered at all. Re-check sits centred at the bottom, outside the group. The app-level "notify me" switch goes with it: macOS's own per-app notification setting already does that job, so notifications now follow the permission alone, asked for by the row's Enable button rather than on first run. Co-Authored-By: Claude Opus 5 --- docs/MODULE-GUIDE.md | 4 +- launcher/macos/README.md | 15 +- launcher/macos/Sources/AppDelegate.swift | 3 - launcher/macos/Sources/LauncherSettings.swift | 5 - launcher/macos/Sources/LauncherView.swift | 32 +- launcher/macos/Sources/Permissions.swift | 17 +- launcher/macos/Sources/PermissionsPane.swift | 322 +++++++++--------- launcher/macos/Sources/ServerController.swift | 9 +- .../macos/Sources/SystemIntegration.swift | 8 +- 9 files changed, 188 insertions(+), 227 deletions(-) diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 1f0e621..5497829 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -237,8 +237,8 @@ it and ask for it before a service rather than during one: | `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 +`reason` is one line, shown to the operator as-is when they hover the +permission; 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. diff --git a/launcher/macos/README.md b/launcher/macos/README.md index 72cec8c..d26892e 100644 --- a/launcher/macos/README.md +++ b/launcher/macos/README.md @@ -78,9 +78,11 @@ 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: +One line per permission: a status dot, its name, and the one thing you can do +about it. What appears is what the installed modules declare in their +manifests (`"permissions": [ … ]` — see +[docs/MODULE-GUIDE.md](../../docs/MODULE-GUIDE.md)); hover a row to see which +module asked, and why. - **Microphone** — asked for directly, with the status macOS reports. - **Local Network** — macOS has no API that reports whether this was granted, @@ -88,8 +90,9 @@ with the module's own reason: 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. +- **Notifications** — the launcher's own, so a server that stops while nobody + is watching still reaches someone. Turn them off for ProdDash in System + Settings if you'd rather not have them. On first run the launcher asks for what the modules declare, while someone is still at the keyboard, rather than halfway through a service. @@ -121,7 +124,7 @@ still at the keyboard, rather than halfway through a service. | 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. | +| "Node.js 18 or newer isn't installed" | Install Node. A GUI app inherits almost no `PATH`, so one in an unusual place may not be found — Settings shows which one is in use. | | "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/Sources/AppDelegate.swift b/launcher/macos/Sources/AppDelegate.swift index db4db69..134aeab 100644 --- a/launcher/macos/Sources/AppDelegate.swift +++ b/launcher/macos/Sources/AppDelegate.swift @@ -299,9 +299,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// Ask for what the installed modules actually declare they need. func requestDeclaredPermissions() { permissions.refresh(root: server.rootURL, dataDir: server.dataDir) - // Notifications are the launcher's own ask, and they are on by default, - // so get the prompt over with now rather than at the first fault. - if settings.notifyOnProblem { permissions.requestNotifications() } for need in permissions.needs { switch need.kind { case .microphone: diff --git a/launcher/macos/Sources/LauncherSettings.swift b/launcher/macos/Sources/LauncherSettings.swift index 6dc6d6d..8846d00 100644 --- a/launcher/macos/Sources/LauncherSettings.swift +++ b/launcher/macos/Sources/LauncherSettings.swift @@ -15,9 +15,6 @@ final class LauncherSettings: ObservableObject { /// Bring the server back if it dies on its own. Backs off, and gives up /// after a burst of fast crashes rather than looping forever. @Published var restartOnCrash: Bool { didSet { defaults.set(restartOnCrash, forKey: "restartOnCrash") } } - /// Notify when the server stops unexpectedly — the point of a booth machine - /// nobody is watching. - @Published var notifyOnProblem: Bool { didSet { defaults.set(notifyOnProblem, forKey: "notifyOnProblem") } } /// Open the dashboard in a browser whenever the server comes up. @Published var openDashboardOnStart: Bool { didSet { defaults.set(openDashboardOnStart, forKey: "openDashboardOnStart") } } /// Show this window at launch. Off means the launcher starts straight into @@ -28,7 +25,6 @@ final class LauncherSettings: ObservableObject { defaults.register(defaults: [ "startServerAtLaunch": true, "restartOnCrash": true, - "notifyOnProblem": true, "openDashboardOnStart": false, "showWindowAtLaunch": true, ]) @@ -36,7 +32,6 @@ final class LauncherSettings: ObservableObject { nodePath = defaults.string(forKey: "nodePath") ?? "" startServerAtLaunch = defaults.bool(forKey: "startServerAtLaunch") restartOnCrash = defaults.bool(forKey: "restartOnCrash") - notifyOnProblem = defaults.bool(forKey: "notifyOnProblem") openDashboardOnStart = defaults.bool(forKey: "openDashboardOnStart") showWindowAtLaunch = defaults.bool(forKey: "showWindowAtLaunch") } diff --git a/launcher/macos/Sources/LauncherView.swift b/launcher/macos/Sources/LauncherView.swift index 64b62e3..40b7e14 100644 --- a/launcher/macos/Sources/LauncherView.swift +++ b/launcher/macos/Sources/LauncherView.swift @@ -43,7 +43,7 @@ struct LauncherView: View { case .settings: SettingsPane(server: server, settings: settings) case .permissions: - PermissionsPane(server: server, settings: settings, permissions: permissions) + PermissionsPane(server: server, permissions: permissions) } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -86,7 +86,6 @@ struct LauncherView: View { private var subtitle: String { var parts: [String] = [] if !server.shellVersion.isEmpty { parts.append("v\(server.shellVersion)") } - parts.append("port \(server.port)") if !server.nodeVersion.isEmpty { parts.append("node \(server.nodeVersion)") } return parts.joined(separator: " · ") } @@ -101,12 +100,6 @@ struct LauncherView: View { ForEach(server.networkURLs, id: \.self) { url in AddressRow(label: "Network", url: url) } - if !server.modulesSummary.isEmpty { - Text("Modules: \(server.modulesSummary)") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } } else { Text("Not serving. ProdDash will come up on port " + String(server.port) + ".") .font(.callout) @@ -268,7 +261,7 @@ private struct SettingsPane: View { var body: some View { Form { - Section("ProdDash") { + Section("Network") { HStack { TextField("Port", text: $portText) .frame(width: 90) @@ -277,13 +270,9 @@ private struct SettingsPane: View { .disabled(Int(portText) == server.port || Int(portText) == nil) Spacer() } - Text("Saved to ProdDash's own settings, so it keeps this port however it is started. " - + "Changing it while the server runs restarts it.") - .font(.caption) - .foregroundStyle(.secondary) } - Section("Starting up") { + Section("Startup") { Toggle("Open ProdDash at login", isOn: Binding( get: { loginItemOn }, set: { setLoginItem($0) })) @@ -309,9 +298,7 @@ private struct SettingsPane: View { } PathRow(title: "Node.js", value: server.nodeURL.map { "\($0.path) \(server.nodeVersion)" } ?? "Not found", - missing: server.nodeURL == nil) { - chooseNode() - } + missing: server.nodeURL == nil, action: nil) PathRow(title: "Settings", value: server.settingsDir.isEmpty ? server.dataDir.path : server.settingsDir, missing: false, action: nil) } @@ -356,17 +343,6 @@ private struct SettingsPane: View { server.refreshEnvironment() } - private func chooseNode() { - let panel = NSOpenPanel() - panel.canChooseFiles = true - panel.canChooseDirectories = false - panel.message = "Choose the node binary ProdDash should run on." - panel.prompt = "Choose" - panel.directoryURL = URL(fileURLWithPath: "/usr/local/bin") - guard panel.runModal() == .OK, let url = panel.url else { return } - settings.nodePath = url.path - server.refreshEnvironment() - } } private struct PathRow: View { diff --git a/launcher/macos/Sources/Permissions.swift b/launcher/macos/Sources/Permissions.swift index 411b473..e391b23 100644 --- a/launcher/macos/Sources/Permissions.swift +++ b/launcher/macos/Sources/Permissions.swift @@ -105,8 +105,6 @@ final class PermissionsModel: ObservableObject { @Published private(set) var notifications: UNAuthorizationStatus = .notDetermined @Published private(set) var hostChecks: [HostCheck] = [] @Published private(set) var checking = false - /// Plain-language read on local network access, since macOS has no API for it. - @Published private(set) var localNetworkVerdict = "" private var browser: NWBrowser? @@ -160,7 +158,6 @@ final class PermissionsModel: ObservableObject { guard !checking else { return } var checks = Self.configuredLocalHosts(root: root, dataDir: dataDir) checking = true - localNetworkVerdict = "" for index in checks.indices { checks[index].result = nil } hostChecks = checks startBonjourBrowse() @@ -169,8 +166,7 @@ final class PermissionsModel: ObservableObject { // Nothing configured yet: the browse alone still raises the prompt. DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in self?.checking = false - self?.localNetworkVerdict = "No local gear is configured yet. Set up ProdCom or ProPresenter in the " - + "admin page, then check again — approve the prompt if macOS asks." + LogStore.shared.launcher("local network check: nothing configured to try yet") } return } @@ -189,16 +185,15 @@ final class PermissionsModel: ObservableObject { self.stopBonjourBrowse() let results = self.hostChecks.compactMap(\.result) let answered = results.filter(\.isReachableHost).count + let verdict: String if answered == results.count { - self.localNetworkVerdict = "Local network access is working — every configured host answered." + verdict = "every configured host answered" } else if answered == 0 { - self.localNetworkVerdict = "Nothing on this network answered. If the gear is powered on, macOS is " - + "withholding local network access from ProdDash — turn it on in System Settings." + verdict = "nothing answered — if the gear is on, macOS is withholding local network access" } else { - self.localNetworkVerdict = "\(answered) of \(results.count) hosts answered. The rest are probably " - + "off or on another network; a blanket failure would point at the permission." + verdict = "\(answered) of \(results.count) hosts answered" } - LogStore.shared.launcher("local network check: \(self.localNetworkVerdict)") + LogStore.shared.launcher("local network check: \(verdict)") } } diff --git a/launcher/macos/Sources/PermissionsPane.swift b/launcher/macos/Sources/PermissionsPane.swift index 851c770..f7d10ae 100644 --- a/launcher/macos/Sources/PermissionsPane.swift +++ b/launcher/macos/Sources/PermissionsPane.swift @@ -3,101 +3,145 @@ import SwiftUI import UserNotifications /** - The permissions pane. + The permissions pane: one line per permission — a status dot, its name, and + the one thing you can do about it. - This is the reason the launcher exists as much as the start button is: macOS - grants privacy permissions to the *app* that owns the process tree, so when - ProdDash runs as this app's child, the prompts are asked in ProdDash's name - and stay granted. What each module needs is read from its own manifest, so - this list matches what is actually installed. + What appears here is what the installed modules declare in their manifests, + so the list matches what is actually installed. Each row's tooltip carries + the modules' own reasons; the row itself stays out of the way. */ struct PermissionsPane: View { @ObservedObject var server: ServerController - @ObservedObject var settings: LauncherSettings @ObservedObject var permissions: PermissionsModel var body: some View { - Form { - Section { - Text("ProdDash's modules ask macOS for these through the launcher. Start the server any other way " - + "— over SSH, from a launchd job — and the permissions belong to that instead, which is why " - + "LTC goes quietly silent when it isn't launched from here.") - .font(.caption) - .foregroundStyle(.secondary) - } - - if permissions.needs.isEmpty { - Section("Modules") { - Text("No installed module declares a permission.") - .foregroundStyle(.secondary) - } - } - - ForEach(permissions.needs) { need in - Section(need.kind.title) { - NeedHeader(need: need) - switch need.kind { - case .microphone: microphoneControls - case .localNetwork: localNetworkControls - case .other: - Text("The launcher doesn't manage this one — grant it in System Settings → Privacy & Security.") - .font(.caption) + VStack(spacing: 0) { + Form { + Section { + if permissions.needs.isEmpty { + Text("No installed module declares a permission.") .foregroundStyle(.secondary) } - } - } - - Section("Notifications") { - HStack(alignment: .firstTextBaseline) { - VStack(alignment: .leading, spacing: 3) { - Text("Tell me when ProdDash stops") - Text("The launcher's own — so a server that stops on a booth machine nobody is watching " - + "still reaches someone.") - .font(.caption) - .foregroundStyle(.secondary) + ForEach(permissions.needs) { need in + row(for: need) } - Spacer() - StatusText(text: notificationLabel, tone: notificationTone) - } - Toggle("Notify me", isOn: Binding( - get: { settings.notifyOnProblem }, - set: { on in - settings.notifyOnProblem = on - if on { permissions.requestNotifications() } - })) - if permissions.notifications == .denied { - Button("Open Notification Settings") { SystemSettings.open(SystemSettings.notifications) } - .controlSize(.small) + notifications } } + .formStyle(.grouped) - Section { - Button("Re-check") { - permissions.refresh(root: server.rootURL, dataDir: server.dataDir) - } + Button("Re-check") { + permissions.refresh(root: server.rootURL, dataDir: server.dataDir) } + .padding(.bottom, 14) } - .formStyle(.grouped) .onAppear { permissions.refresh(root: server.rootURL, dataDir: server.dataDir) } } - // MARK: - Microphone + // MARK: - Rows - private var microphoneControls: some View { - HStack { - StatusText(text: permissions.microphone.description, tone: microphoneTone) - Spacer() - switch permissions.microphone { - case .notDetermined: - Button("Allow…") { permissions.requestMicrophone() } - case .denied, .restricted: - Button("Open System Settings") { SystemSettings.open(SystemSettings.microphone) } - default: - Button("Open System Settings") { SystemSettings.open(SystemSettings.microphone) } - .controlSize(.small) + @ViewBuilder + private func row(for need: ModuleNeed) -> some View { + switch need.kind { + case .microphone: + PermissionRow(title: need.kind.title, + tone: microphoneTone, + help: help(for: need), + actionTitle: microphoneActionTitle, + action: microphoneActionTitle == nil ? nil : { requestMicrophone() }) + + case .localNetwork: + VStack(alignment: .leading, spacing: 7) { + PermissionRow(title: need.kind.title, + tone: localNetworkTone, + help: help(for: need), + busy: permissions.checking, + actionTitle: "Check Access", + action: { permissions.checkLocalNetwork(root: server.rootURL, dataDir: server.dataDir) }) + if !results.isEmpty { hostResults } + } + + case .other: + PermissionRow(title: need.kind.title, + tone: .unknown, + help: "The launcher doesn't manage this one — grant it in System Settings.\n" + + help(for: need), + actionTitle: "Open System Settings", + action: { SystemSettings.open(SystemSettings.privacy) }) + } + } + + private var notifications: some View { + PermissionRow(title: "Notifications", + tone: notificationTone, + help: "Tells you when ProdDash stops on its own.", + actionTitle: notificationActionTitle, + action: notificationActionTitle == nil ? nil : { permissions.requestNotifications() }) + } + + /// What the modules said, for the tooltip — one line per distinct reason. + private func help(for need: ModuleNeed) -> String { + need.claims + .map { $0.modules.isEmpty ? $0.reason : "\($0.reason) — \($0.askedBy)" } + .joined(separator: "\n") + } + + // MARK: - Local network results + + private var results: [Net.Probe] { permissions.hostChecks.compactMap(\.result) } + + private var hostResults: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(permissions.hostChecks) { check in + HStack(spacing: 8) { + Circle() + .fill(tone(for: check.result).color) + .frame(width: 6, height: 6) + Text(check.label) + .font(.system(.caption, design: .monospaced)) + Spacer() + Text(describe(check.result)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + if !results.isEmpty, !results.contains(where: \.isReachableHost) { + // Only when everything failed, because that is the shape a + // withheld permission takes. + HStack(spacing: 8) { + Text("Nothing answered.") + .font(.caption) + .foregroundStyle(.secondary) + Button("Open System Settings") { SystemSettings.open(SystemSettings.localNetwork) } + .buttonStyle(.link) + .controlSize(.small) + } } } + .padding(.leading, 17) + } + + private func describe(_ result: Net.Probe?) -> String { + switch result { + case .none: return "—" + case .open: return "answered" + case .refused: return "reachable" + case .unreachable: return "no route" + case .timedOut: return "no answer" + case .failed(let why): return why + } + } + + private func tone(for result: Net.Probe?) -> Tone { + guard let result else { return .unknown } + return result.isReachableHost ? .good : .bad + } + + // MARK: - Status and actions + + private func requestMicrophone() { + permissions.requestMicrophone() } private var microphoneTone: Tone { @@ -108,77 +152,19 @@ struct PermissionsPane: View { } } - // MARK: - Local network - - private var localNetworkControls: some View { - VStack(alignment: .leading, spacing: 8) { - Text("macOS never reports whether this was granted, so the check below asks the network directly: " - + "it browses for services (which is what raises the prompt) and tries every upstream the admin " - + "page is pointed at.") - .font(.caption) - .foregroundStyle(.secondary) - - HStack(spacing: 8) { - Button(permissions.checking ? "Checking…" : "Check Access") { - permissions.checkLocalNetwork(root: server.rootURL, dataDir: server.dataDir) - } - .disabled(permissions.checking) - if permissions.checking { - ProgressView().controlSize(.small) - } - Spacer() - Button("Open System Settings") { SystemSettings.open(SystemSettings.localNetwork) } - .controlSize(.small) - } - - ForEach(permissions.hostChecks) { check in - HStack(spacing: 8) { - Circle() - .fill(tone(for: check.result).color) - .frame(width: 7, height: 7) - Text(check.label) - .font(.system(.caption, design: .monospaced)) - Text(check.module) - .font(.caption) - .foregroundStyle(.tertiary) - Spacer() - Text(describe(check.result)) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - if !permissions.localNetworkVerdict.isEmpty { - Text(permissions.localNetworkVerdict) - .font(.caption) - .foregroundStyle(permissions.localNetworkVerdict.contains("working") ? .secondary : .primary) - } + private var microphoneActionTitle: String? { + switch permissions.microphone { + case .authorized: return nil + case .notDetermined: return "Allow" + default: return "Open System Settings" } } - private func describe(_ result: Net.Probe?) -> String { - switch result { - case .none: return "—" - case .open: return "answered" - case .refused: return "reachable, nothing on that port" - case .unreachable: return "no route" - case .timedOut: return "no answer" - case .failed(let why): return why - } - } - - private func tone(for result: Net.Probe?) -> Tone { - guard let result else { return .unknown } - return result.isReachableHost ? .good : .bad - } - - private var notificationLabel: String { - switch permissions.notifications { - case .authorized, .provisional, .ephemeral: return "granted" - case .denied: return "denied" - case .notDetermined: return "not asked yet" - @unknown default: return "unknown" - } + /// Anything answering means the permission is there; only a clean sweep of + /// failures points at macOS withholding it. + private var localNetworkTone: Tone { + guard !results.isEmpty else { return .unknown } + return results.contains(where: \.isReachableHost) ? .good : .bad } private var notificationTone: Tone { @@ -188,9 +174,17 @@ struct PermissionsPane: View { default: return .unknown } } + + private var notificationActionTitle: String? { + switch permissions.notifications { + case .authorized, .provisional, .ephemeral: return nil + case .notDetermined: return "Enable" + default: return "Open System Settings" + } + } } -// MARK: - Small pieces +// MARK: - Pieces enum Tone { case good, bad, unknown @@ -202,33 +196,39 @@ enum Tone { case .unknown: return .secondary } } -} -private struct StatusText: View { - let text: String - let tone: Tone - - var body: some View { - HStack(spacing: 6) { - Circle().fill(tone.color).frame(width: 7, height: 7) - Text(text).font(.callout) + /// Said out loud for anyone who can't see the dot. + var word: String { + switch self { + case .good: return "allowed" + case .bad: return "not allowed" + case .unknown: return "not checked" } } } -private struct NeedHeader: View { - let need: ModuleNeed +private struct PermissionRow: View { + let title: String + let tone: Tone + let help: String + var busy = false + var actionTitle: String? + var action: (() -> Void)? var body: some View { - VStack(alignment: .leading, spacing: 8) { - ForEach(need.claims) { claim in - VStack(alignment: .leading, spacing: 3) { - Text(claim.reason) - Text("Needed by \(claim.askedBy)") - .font(.caption) - .foregroundStyle(.secondary) - } + HStack(spacing: 9) { + Circle() + .fill(tone.color) + .frame(width: 8, height: 8) + .accessibilityHidden(true) // the name carries the status instead + Text(title) + .accessibilityLabel("\(title): \(tone.word)") + Spacer() + if busy { ProgressView().controlSize(.small) } + if let actionTitle, let action { + Button(actionTitle, action: action).disabled(busy) } } + .help(help) } } diff --git a/launcher/macos/Sources/ServerController.swift b/launcher/macos/Sources/ServerController.swift index b8f6421..c2a7bc6 100644 --- a/launcher/macos/Sources/ServerController.swift +++ b/launcher/macos/Sources/ServerController.swift @@ -47,7 +47,6 @@ final class ServerController: ObservableObject { @Published private(set) var shellVersion = "" @Published private(set) var localURL = "" @Published private(set) var networkURLs: [String] = [] - @Published private(set) var modulesSummary = "" @Published private(set) var settingsDir = "" /// The server answered /api/modules — listening, not merely alive. @Published private(set) var healthy = false @@ -125,7 +124,6 @@ final class ServerController: ObservableObject { message = "" localURL = "" networkURLs = [] - modulesSummary = "" healthy = false healthMisses = 0 openedDashboardThisRun = false @@ -283,7 +281,6 @@ final class ServerController: ObservableObject { switch field { case "Local": localURL = value case "Network": if !networkURLs.contains(value) { networkURLs.append(value) } - case "Modules": modulesSummary = value case "Settings": settingsDir = value default: break } @@ -330,16 +327,14 @@ final class ServerController: ObservableObject { : "ProdDash \(how)." let last = lastProblemLine.isEmpty ? "" : " Last message: \(lastProblemLine)" fail(why + last) - Notifier.shared.post(title: "ProdDash stopped", body: why, if: settings.notifyOnProblem) + Notifier.shared.post(title: "ProdDash stopped", body: why) return } let delay = [1.0, 2.0, 5.0, 10.0][min(crashTimes.count - 1, 3)] state = .stopped message = "ProdDash \(how) — restarting…" log.launcher("ProdDash \(how)\(detail); restarting in \(Int(delay))s") - Notifier.shared.post(title: "ProdDash stopped", - body: "It \(how)\(detail). Restarting…", - if: settings.notifyOnProblem) + Notifier.shared.post(title: "ProdDash stopped", body: "It \(how)\(detail). Restarting…") scheduleRestart(after: delay) } diff --git a/launcher/macos/Sources/SystemIntegration.swift b/launcher/macos/Sources/SystemIntegration.swift index d970f02..c9d7ecc 100644 --- a/launcher/macos/Sources/SystemIntegration.swift +++ b/launcher/macos/Sources/SystemIntegration.swift @@ -40,10 +40,9 @@ final class Notifier { /// Posting never asks for permission: a prompt that appears because /// something just failed, unattended, is the worst possible moment for it. - /// Authorization is asked for on first run and when the setting is turned - /// on; until then this quietly does nothing. - func post(title: String, body: String, if enabled: Bool) { - guard enabled, available, authorized else { return } + /// It is asked for from the Permissions tab; until then this does nothing. + func post(title: String, body: String) { + guard available, authorized else { return } let content = UNMutableNotificationContent() content.title = title content.body = body @@ -92,4 +91,5 @@ enum SystemSettings { static let microphone = "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone" static let localNetwork = "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork" static let notifications = "x-apple.systempreferences:com.apple.Notifications-Settings.extension" + static let privacy = "x-apple.systempreferences:com.apple.preference.security?Privacy" } From adac3a4785bc60706866154bad8766db33c239f9 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Fri, 11 Sep 2026 12:38:46 -0400 Subject: [PATCH 3/3] Ship ProdDash as one app: its own Node, its own copy, a .dmg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installing is dragging ProdDash to Applications. The app carries the dashboard and the Node that runs it, so nothing has to be installed first and nothing can go missing underneath it. ProdDash.app/Contents/ MacOS/ProdDash the launcher Resources/node/bin/node Node 24.21.0 LTS, fetched and checksum-verified Resources/app/ server.js, public/, modules/ Everything mutable stays where it already was, in ~/Library/Application Support/ProdDash — and that now includes app/, the copy of ProdDash that actually runs. The copy is not ceremony: ProdDash updates itself in place, and nothing may write inside a signed .app. So the bundle holds a read-only baseline and the data directory holds the running copy, which means both routes to a new version keep working and don't fight — installing a newer .app replaces the copy, while a copy that has already updated itself past the bundle is left alone. build.sh now assembles all of it: fetches Node against nodejs.org's published checksums (cached in .cache/), embeds the server, compiles each module's native helper so the Mac running ProdDash needs no compiler, signs nested code before the bundle, and builds the disk image. --dev skips the payload for launcher work, which still runs against a checkout. Signing is set up for a Developer ID with the hardened runtime and notarisation (--notarize), and falls back to ad-hoc when no such certificate is installed. Entitlements are split by what each binary actually does: JIT for node, audio input for the launcher and for module helpers that read one. Built and run end to end: first-run install, bundled node, dashboard and admin serving, re-seed in both directions (newer app replaces an older copy; an updated copy survives), disk image mounts and its app verifies. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 +- CLAUDE.md | 5 +- README.md | 32 ++- launcher/macos/README.md | 156 ++++++++----- launcher/macos/Resources/Info.plist | 2 + .../macos/Resources/ProdDash.entitlements | 10 + launcher/macos/Resources/helper.entitlements | 10 + launcher/macos/Resources/node.entitlements | 15 ++ launcher/macos/Sources/AppDelegate.swift | 4 +- launcher/macos/Sources/LauncherConfig.swift | 99 +++++++- launcher/macos/Sources/ServerController.swift | 8 +- launcher/macos/build.sh | 214 +++++++++++++----- 12 files changed, 435 insertions(+), 123 deletions(-) create mode 100644 launcher/macos/Resources/ProdDash.entitlements create mode 100644 launcher/macos/Resources/helper.entitlements create mode 100644 launcher/macos/Resources/node.entitlements diff --git a/.gitignore b/.gitignore index 450fd9c..1e30fc9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,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) +# the launcher's build output, and its cache of the Node runtime it embeds launcher/macos/build/ +launcher/macos/.cache/ diff --git a/CLAUDE.md b/CLAUDE.md index ca9a420..ba8b906 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,8 +4,9 @@ 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/`, 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 +`launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries +the server and its own Node runtime, supervises them, 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. diff --git a/README.md b/README.md index 16261e6..80696b3 100644 --- a/README.md +++ b/README.md @@ -29,21 +29,28 @@ and a clear degraded state, then recover on their own. ## Run it -Requires [Node.js](https://nodejs.org) 18 or newer. +### macOS — the app -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: +Open the disk image and drag **ProdDash** to Applications. That's the whole +installation: the app carries the dashboard and its own Node runtime, so +nothing else has to be installed and nothing can be missing. It lives in the +menu bar, starts and stops the server, can open at login, 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 the disk image from a checkout with +[launcher/macos](launcher/macos)/`build.sh`: ```bash -cd launcher/macos && ./build.sh --install +cd launcher/macos && ./build.sh --dmg ``` -Otherwise, from the app folder: +### Anywhere else, or from a checkout + +Requires [Node.js](https://nodejs.org) 18 or newer. ```bash node server.js @@ -81,7 +88,10 @@ it at startup (`Settings : …`). The directory holds `modules.json` (module config), `layouts/` (named layouts) and `modules/` (modules installed from the admin page), all written -by the app itself. It may also hold a `proddash.json` +by the app itself. Installed from the macOS app, it also holds `app/` — the +copy of ProdDash that actually runs, laid down from inside the app bundle on +first launch and updated in place from then on (nothing may write inside a +signed .app, and ProdDash updates itself). It may also hold a `proddash.json` with this machine's shell settings — the same keys as the checked-in [config/proddash.json](config/proddash.json), which supplies the defaults: diff --git a/launcher/macos/README.md b/launcher/macos/README.md index d26892e..9ea0e6d 100644 --- a/launcher/macos/README.md +++ b/launcher/macos/README.md @@ -1,50 +1,104 @@ -# ProdDash Launcher (macOS) +# ProdDash for 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**. +The Mac application: a menu-bar app that carries the dashboard, its own Node +runtime, and the permissions its modules need. Installing it is dragging +**ProdDash** to Applications — nothing else has to be present on the machine, +and nothing can go missing from under it. -It is a native Swift app: about 1 MB, no dependencies, nothing to install but -the app itself. Build it with `./build.sh`. +Build it with `./build.sh`. The app is native Swift with no dependencies of +its own; the Node it ships is fetched from nodejs.org at build time and +checksum-verified. -## Why a launcher, and not just `Start ProdDash.command` +## What's in the bundle -Two reasons, and the second is the one that bites. +``` +ProdDash.app/Contents/ + MacOS/ProdDash the launcher (Swift, ~1 MB) + Resources/node/bin/node the Node runtime ProdDash runs on + Resources/app/ server.js, public/, modules/ — ProdDash itself +``` -**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. +Everything mutable lives outside, in `~/Library/Application Support/ProdDash`: +module config, layouts, modules installed from the admin page — and `app/`, +the copy of ProdDash that actually runs. + +**Why a copy, when the bundle already has one.** ProdDash updates itself: the +admin page's **Update now** downloads a new version and writes it over the app +folder. Nothing may write inside a signed `.app` without breaking its +signature, so the bundle carries a read-only baseline and the data directory +carries the copy that runs. Both routes to a new version work, and they don't +fight: installing a newer `.app` replaces the copy, while a copy that has +already updated itself past the bundle is left alone. + +## Why it exists **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. +owns a process, and a child inherits its parent's identity. Started from here, +node — and the Timers module's `ltc-capture` — belong to this app bundle, so +the microphone and local-network prompts are asked, and remembered, as +ProdDash. Started over SSH or from a bare launchd job there is nobody to ask: +the permission is silently absent rather than refused, which is the "endless +zeroes, no error" failure `ltc-listener.js` warns about. + +**Supervision.** The shell was built for this: with `PRODDASH_LAUNCHER=1` it +exits **75** instead of respawning itself when the admin page applies an +update, which the launcher reads as "start me again" rather than as a fault. +A crash gets a backed-off restart (1s, 2s, 5s, 10s); four in two minutes stops +the cycle rather than spinning forever. ## 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 +./build.sh # build/ProdDash.app — the whole application +./build.sh --dmg # …and build/ProdDash--arm64.dmg +./build.sh --install # …and copy it to /Applications +./build.sh --run # …and open it +./build.sh --notarize # with --dmg: notarise and staple (Developer ID) +./build.sh --dev # launcher only: no Node, no server — runs against + # the checkout. Seconds, for work on the launcher. ``` 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. +newer. The first build downloads Node (~50 MB) into `.cache/` and reuses it. +A full build takes about ten seconds and produces a 117 MB app, 43 MB +compressed in the disk image. -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. +Builds for **Apple Silicon**. To change that, or to move to a newer Node, edit +`ARCH` and `NODE_VERSION` at the top of `build.sh` — the download is verified +against nodejs.org's published checksums either way. + +The app icon is built from `public/icons/icon-512.png`, so ProdDash's own icon +is the app's icon. A module's native helper is compiled during the build +(`.swift` beside a module's files becomes ``), so the Mac running +ProdDash never needs a compiler. + +## Signing + +`build.sh` uses a **Developer ID Application** certificate when one is +installed, and signs ad-hoc when none is — which is fine on your own Macs, but +another Mac will refuse the app until someone approves it under System +Settings → Privacy & Security. + +To sign properly you need the Apple Developer Program (paid) and a Developer +ID certificate — note this is not the same as the "Apple Development" +certificate Xcode creates for you: + +1. Xcode → Settings → Accounts → your team → **Manage Certificates** → + **+** → **Developer ID Application**. (Requires Account Holder or Admin + on the team.) `security find-identity -v -p codesigning` should then + list a `Developer ID Application: …` identity. +2. Store notarisation credentials once, with an app-specific password from + [appleid.apple.com](https://appleid.apple.com): + + ```bash + xcrun notarytool store-credentials proddash --apple-id you@example.com --team-id TEAMID + ``` + +3. `./build.sh --notarize` + +Then the disk image opens by double-click on any Mac, with no warnings. +`CODESIGN_IDENTITY` overrides the certificate; `NOTARY_PROFILE` overrides the +keychain profile name. ## Using it @@ -56,7 +110,8 @@ hollow when it is stopped. Its menu has the whole app in it: - **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. +- **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: @@ -64,10 +119,10 @@ 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. + restart on crash, open a browser, show this window). It also shows which + copy of ProdDash is running, which node, and where settings are kept. +- **Permissions** — one line per permission: a status dot, its name, and the + one thing you can do about it. See below. ### Port @@ -78,9 +133,8 @@ it while the server runs restarts it. ### Permissions -One line per permission: a status dot, its name, and the one thing you can do -about it. What appears is what the installed modules declare in their -manifests (`"permissions": [ … ]` — see +What appears is what the installed modules declare in their manifests +(`"permissions": [ … ]` — see [docs/MODULE-GUIDE.md](../../docs/MODULE-GUIDE.md)); hover a row to see which module asked, and why. @@ -88,8 +142,8 @@ module asked, and why. - **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. + admin page is pointed at. Nothing answering, when the gear is on, means + macOS is withholding access. - **Notifications** — the launcher's own, so a server that stops while nobody is watching still reaches someone. Turn them off for ProdDash in System Settings if you'd rather not have them. @@ -110,21 +164,17 @@ still at the keyboard, rather than halfway through a service. 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. +- **Nothing is required outside the app.** A development build (`--dev`) + carries no server and no Node, and runs the checkout it was built in with + whatever node is on the machine — Settings → ProdDash folder points it + somewhere else. ## 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. A GUI app inherits almost no `PATH`, so one in an unusual place may not be found — Settings shows which one is in use. | +| "ProdDash can't be opened because Apple cannot check it" | The app is ad-hoc signed. System Settings → Privacy & Security → **Open Anyway**, once. Signing it properly (above) removes this. | | "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. | +| 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 app — Stop, then Start. | | Nothing on the network answers | Permissions tab → **Check Access**. | +| A development build says it can't find ProdDash | `--dev` builds need a checkout: Settings → ProdDash folder. | diff --git a/launcher/macos/Resources/Info.plist b/launcher/macos/Resources/Info.plist index 97d105d..9108e0b 100644 --- a/launcher/macos/Resources/Info.plist +++ b/launcher/macos/Resources/Info.plist @@ -16,6 +16,8 @@ 1.0.0 CFBundleVersion 1.0.0 + ProdDashLauncherVersion + 1.0.0 CFBundleIconFile AppIcon LSApplicationCategoryType diff --git a/launcher/macos/Resources/ProdDash.entitlements b/launcher/macos/Resources/ProdDash.entitlements new file mode 100644 index 0000000..0cf521e --- /dev/null +++ b/launcher/macos/Resources/ProdDash.entitlements @@ -0,0 +1,10 @@ + + + + + + com.apple.security.device.audio-input + + + diff --git a/launcher/macos/Resources/helper.entitlements b/launcher/macos/Resources/helper.entitlements new file mode 100644 index 0000000..98ab339 --- /dev/null +++ b/launcher/macos/Resources/helper.entitlements @@ -0,0 +1,10 @@ + + + + + + com.apple.security.device.audio-input + + + diff --git a/launcher/macos/Resources/node.entitlements b/launcher/macos/Resources/node.entitlements new file mode 100644 index 0000000..60cef26 --- /dev/null +++ b/launcher/macos/Resources/node.entitlements @@ -0,0 +1,15 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.cs.disable-library-validation + + + diff --git a/launcher/macos/Sources/AppDelegate.swift b/launcher/macos/Sources/AppDelegate.swift index 134aeab..5c6cf81 100644 --- a/launcher/macos/Sources/AppDelegate.swift +++ b/launcher/macos/Sources/AppDelegate.swift @@ -10,8 +10,10 @@ import Combine */ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { + /// The launcher's own version. The app's CFBundleShortVersionString is + /// ProdDash's, because the app *is* ProdDash now. static var version: String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" + Bundle.main.infoDictionary?["ProdDashLauncherVersion"] as? String ?? "dev" } /// Sent by a second copy of the launcher so the first one shows itself. static let showNotification = Notification.Name("org.waterschurch.proddash.launcher.show") diff --git a/launcher/macos/Sources/LauncherConfig.swift b/launcher/macos/Sources/LauncherConfig.swift index 7268fdd..eb859a3 100644 --- a/launcher/macos/Sources/LauncherConfig.swift +++ b/launcher/macos/Sources/LauncherConfig.swift @@ -32,6 +32,95 @@ enum ProdDash { .appendingPathComponent("Library/Application Support/ProdDash") } + // MARK: - What the app carries + + /** + A shipped ProdDash lives read-only inside the app bundle and runs from a + copy in the data directory. + + The copy isn't ceremony: ProdDash updates itself in place — the admin + page's "Update now" writes a fresh download over the app folder — and + nothing may write inside a signed .app without breaking its signature. + So the bundle carries the baseline, the data directory carries the copy + that actually runs, and both routes to a new version keep working: drop + in a newer .app, or press Update now. + + A development build ships no payload and falls through to the checkout it + was built in. + */ + static func bundledPayload() -> URL? { + guard let resources = Bundle.main.resourceURL else { return nil } + let payload = resources.appendingPathComponent("app") + return isRoot(payload) ? payload : nil + } + + /// The Node the app carries, so nothing depends on what this Mac happens + /// to have installed — or on it staying installed. + static func bundledNode() -> URL? { + guard let resources = Bundle.main.resourceURL else { return nil } + let node = resources.appendingPathComponent("node/bin/node") + return FileManager.default.isExecutableFile(atPath: node.path) ? node : nil + } + + static func workingCopy(dataDir: URL) -> URL { dataDir.appendingPathComponent("app") } + + /** + Put the bundled ProdDash in place: when there is no copy yet, or when the + app carries a newer one than the copy — someone installed a new .app. A + copy that has updated itself *past* the bundle is left alone, so a new + install never drags a running system backwards. + */ + @discardableResult + static func seedWorkingCopy(dataDir: URL, log: (String) -> Void) -> URL? { + guard let payload = bundledPayload() else { return nil } + let working = workingCopy(dataDir: dataDir) + let shipped = shellVersion(root: payload) + if isRoot(working) { + let installed = shellVersion(root: working) + guard compareVersions(shipped, installed) > 0 else { return working } + log("this app carries ProdDash \(shipped) — replacing the \(installed) copy it runs") + } else { + log("first run — installing ProdDash \(shipped)") + } + + let fm = FileManager.default + let previous = working.appendingPathExtension("previous") + do { + try fm.createDirectory(at: dataDir, withIntermediateDirectories: true) + try? fm.removeItem(at: previous) + if fm.fileExists(atPath: working.path) { try fm.moveItem(at: working, to: previous) } + try fm.copyItem(at: payload, to: working) + makeWritable(working) + try? fm.removeItem(at: previous) + return working + } catch { + log("could not install the bundled ProdDash: \(error.localizedDescription)") + // Put back whatever was working before rather than leaving nothing. + if !fm.fileExists(atPath: working.path), fm.fileExists(atPath: previous.path) { + try? fm.moveItem(at: previous, to: working) + } + return isRoot(working) ? working : nil + } + } + + /// Files copied out of a bundle can arrive read-only, and this copy is + /// exactly where ProdDash writes its own updates. + private static func makeWritable(_ root: URL) { + let fm = FileManager.default + var targets = [root] + if let walker = fm.enumerator(at: root, includingPropertiesForKeys: nil) { + for case let url as URL in walker { targets.append(url) } + } + for url in targets { + guard let mode = (try? fm.attributesOfItem(atPath: url.path))?[.posixPermissions] as? NSNumber + else { continue } + let writable = mode.uint16Value | 0o200 + if writable != mode.uint16Value { + try? fm.setAttributes([.posixPermissions: NSNumber(value: writable)], ofItemAtPath: url.path) + } + } + } + // MARK: - Finding the checkout /// A folder is ProdDash if it has the two files the server cannot run without. @@ -46,7 +135,7 @@ enum ProdDash { 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? { + static func findRoot(preferred: String?, dataDir: URL) -> URL? { if let preferred, !preferred.isEmpty { let url = URL(fileURLWithPath: preferred).standardizedFileURL if isRoot(url) { return url } @@ -55,6 +144,12 @@ enum ProdDash { let url = URL(fileURLWithPath: env).standardizedFileURL if isRoot(url) { return url } } + // A shipped app runs the copy it installed; only a development build + // (no payload) goes looking for a checkout. + if bundledPayload() != nil { + let working = workingCopy(dataDir: dataDir) + if isRoot(working) { return working } + } var up = Bundle.main.bundleURL.standardizedFileURL for _ in 0..<6 { up.deleteLastPathComponent() @@ -89,6 +184,8 @@ enum ProdDash { static func findNode(root: URL?, preferred: String?) -> URL? { var tries: [URL] = [] if let preferred, !preferred.isEmpty { tries.append(URL(fileURLWithPath: preferred)) } + // What the app carries, so a shipped ProdDash needs nothing installed. + if let bundled = bundledNode() { tries.append(bundled) } // 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"] diff --git a/launcher/macos/Sources/ServerController.swift b/launcher/macos/Sources/ServerController.swift index c2a7bc6..f4eabb4 100644 --- a/launcher/macos/Sources/ServerController.swift +++ b/launcher/macos/Sources/ServerController.swift @@ -82,7 +82,13 @@ final class ServerController: ObservableObject { /// 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) + // A shipped app installs (or re-installs) its copy of ProdDash before + // going looking for one. An explicitly chosen folder skips all that. + let base = ProdDash.dataDir(root: nil) + if settings.rootPath.isEmpty { + ProdDash.seedWorkingCopy(dataDir: base) { [log] message in log.launcher(message) } + } + let root = ProdDash.findRoot(preferred: settings.rootPath, dataDir: base) rootURL = root let node = ProdDash.findNode(root: root, preferred: settings.nodePath) if node?.path != nodeURL?.path { diff --git a/launcher/macos/build.sh b/launcher/macos/build.sh index 1019e1a..5bb9b0b 100755 --- a/launcher/macos/build.sh +++ b/launcher/macos/build.sh @@ -1,72 +1,92 @@ #!/bin/zsh -# Build ProdDash.app — the menu-bar launcher. +# Build ProdDash.app — the whole application: launcher, server and its own +# Node runtime, with nothing to install first. # -# ./build.sh build into launcher/macos/build/ProdDash.app +# ./build.sh build/ProdDash.app, self-contained +# ./build.sh --dmg …and build/ProdDash--arm64.dmg # ./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) +# ./build.sh --run …and launch it +# ./build.sh --notarize with --dmg: notarise and staple (needs a +# Developer ID and a notarytool keychain profile) +# ./build.sh --dev launcher only — no Node, no server payload, so +# it runs against the checkout. Seconds, not +# minutes, while working on the launcher itself. # -# Needs the Xcode command line tools (swiftc). No other dependencies — the app -# is Swift and system frameworks, nothing to install and nothing to vendor. +# Signing: a "Developer ID Application" certificate is used when one is +# installed (override with CODESIGN_IDENTITY). Without one the app is signed +# ad-hoc, which is fine on your own Macs but makes another Mac ask before it +# will open the app. +# +# Needs the Xcode command line tools. Everything else it fetches or builds. set -e -u cd "$(dirname "$0")" APP_NAME="ProdDash" BUNDLE_ID="org.waterschurch.proddash" +LAUNCHER_VERSION="1.0.0" +# The Node the app ships. Bump it here; the download is checksum-verified +# against nodejs.org's own SHASUMS256.txt and cached in .cache/. +NODE_VERSION="v24.21.0" +ARCH="arm64" +DEPLOYMENT_TARGET="13.0" + +REPO_ROOT="../.." OUT_DIR="build" +CACHE_DIR=".cache" APP="$OUT_DIR/$APP_NAME.app" -ARCHS=(arm64 x86_64) +CONTENTS="$APP/Contents" +PAYLOAD="$CONTENTS/Resources/app" + +DEV=0 INSTALL=0 RUN=0 +MAKE_DMG=0 +NOTARIZE=0 while [[ $# -gt 0 ]]; do case "$1" in + --dev) DEV=1 ;; --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 ;; + --dmg) MAKE_DMG=1 ;; + --notarize) NOTARIZE=1; MAKE_DMG=1 ;; + -h|--help) sed -n '2,22p' "$0"; exit 0 ;; *) echo "build.sh: unknown option $1" >&2; exit 2 ;; esac shift done -if ! command -v swiftc >/dev/null; then +command -v swiftc >/dev/null || { 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[*]}" +# The product's version is ProdDash's own; the launcher keeps its own number +# for when something is wrong with the launcher rather than the dashboard. +VERSION=$(sed -n 's/.*"version" *: *"\([^"]*\)".*/\1/p' "$REPO_ROOT/package.json" | head -1) +[[ -n "$VERSION" ]] || { echo "build.sh: can't read the version from package.json" >&2; exit 1; } + +echo "ProdDash $VERSION (launcher $LAUNCHER_VERSION) — building for $ARCH" rm -rf "$OUT_DIR" -mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$OUT_DIR/obj" +mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" -# 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 +# ── the launcher ───────────────────────────────────────────────────────── +echo " compiling the launcher" +swiftc -O -whole-module-optimization \ + -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" \ + -module-name ProdDashLauncher \ + -o "$CONTENTS/MacOS/$APP_NAME" \ + Sources/*.swift -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 "$CONTENTS/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$CONTENTS/Info.plist" >/dev/null +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$CONTENTS/Info.plist" >/dev/null +/usr/libexec/PlistBuddy -c "Set :ProdDashLauncherVersion $LAUNCHER_VERSION" "$CONTENTS/Info.plist" >/dev/null +printf 'APPL????' > "$CONTENTS/PkgInfo" -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" +SOURCE_ICON="$REPO_ROOT/public/icons/icon-512.png" if [[ -f "$SOURCE_ICON" ]]; then echo " building the icon" ICONSET="$OUT_DIR/AppIcon.iconset" @@ -74,33 +94,122 @@ if [[ -f "$SOURCE_ICON" ]]; then 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" + iconutil -c icns "$ICONSET" -o "$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" +# ── the server, and the Node that runs it ──────────────────────────────── +if [[ $DEV -eq 0 ]]; then + TARBALL="node-$NODE_VERSION-darwin-$ARCH.tar.gz" + mkdir -p "$CACHE_DIR" + if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then + echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)" + curl -fsSL --retry 3 -o "$CACHE_DIR/$TARBALL.part" "https://nodejs.org/dist/$NODE_VERSION/$TARBALL" + mv "$CACHE_DIR/$TARBALL.part" "$CACHE_DIR/$TARBALL" + fi + SUMS="$CACHE_DIR/SHASUMS256-$NODE_VERSION.txt" + [[ -f "$SUMS" ]] || curl -fsSL --retry 3 -o "$SUMS" "https://nodejs.org/dist/$NODE_VERSION/SHASUMS256.txt" + # This binary is about to be signed with your name on it — check it every build. + WANT=$(awk -v f="$TARBALL" '$2 == f {print $1}' "$SUMS") + GOT=$(shasum -a 256 "$CACHE_DIR/$TARBALL" | awk '{print $1}') + if [[ -z "$WANT" || "$WANT" != "$GOT" ]]; then + echo "build.sh: $TARBALL does not match nodejs.org's checksum — refusing to ship it" >&2 + exit 1 + fi + echo " embedding Node $NODE_VERSION" + mkdir -p "$CONTENTS/Resources/node/bin" + tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \ + "node-$NODE_VERSION-darwin-$ARCH/bin/node" -# 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 " embedding ProdDash $VERSION" + mkdir -p "$PAYLOAD/config" + cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$PAYLOAD/" + cp -R "$REPO_ROOT/public" "$REPO_ROOT/modules" "$PAYLOAD/" + # Only the checked-in defaults: config/ also holds this machine's own + # modules.json and layouts when ProdDash has ever run from the checkout. + cp "$REPO_ROOT/config/proddash.json" "$PAYLOAD/config/" + find "$PAYLOAD" -name '.DS_Store' -delete -echo "built $APP" + # A module's native helper is built here, so the Mac running ProdDash needs + # no compiler: .swift beside a module's files becomes . + for source in "$PAYLOAD"/modules/*/*.swift; do + [[ -e "$source" ]] || continue + helper="${source%.swift}" + echo " compiling $(basename "$(dirname "$source")")/$(basename "$helper")" + swiftc -O -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$helper" "$source" + done +fi + +# ── signing ────────────────────────────────────────────────────────────── +IDENTITY="${CODESIGN_IDENTITY:-}" +if [[ -z "$IDENTITY" ]]; then + IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \ + | awk -F'"' '/Developer ID Application/ {print $2; exit}') +fi +if [[ -n "$IDENTITY" ]]; then + echo " signing as $IDENTITY" + SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp) +else + echo " signing ad-hoc (no Developer ID installed — other Macs will ask before opening it)" + IDENTITY="-" + SIGN=(--force --sign -) +fi + +# Nested code first, outermost last, or the outer signature seals a lie. +if [[ -f "$CONTENTS/Resources/node/bin/node" ]]; then + codesign "${SIGN[@]}" --entitlements Resources/node.entitlements "$CONTENTS/Resources/node/bin/node" +fi +if [[ $DEV -eq 0 ]]; then + for helper in "$PAYLOAD"/modules/*/*; do + # A compiled helper is the extensionless executable beside a module's + # sources — test the name, not the path, which has dots all over it. + name=$(basename "$helper") + [[ -f "$helper" && -x "$helper" && "$name" != *.* ]] || continue + echo " signing $name" + codesign "${SIGN[@]}" --entitlements Resources/helper.entitlements "$helper" + done +fi +codesign "${SIGN[@]}" --entitlements Resources/ProdDash.entitlements --identifier "$BUNDLE_ID" "$APP" +codesign --verify --deep --strict "$APP" && echo " signature verifies" + +SIZE=$(du -sh "$APP" | awk '{print $1}') +echo "built $APP ($SIZE)" + +# ── disk image ─────────────────────────────────────────────────────────── +if [[ $MAKE_DMG -eq 1 ]]; then + DMG="$OUT_DIR/$APP_NAME-$VERSION-$ARCH.dmg" + STAGE="$OUT_DIR/dmg" + rm -rf "$STAGE" + mkdir -p "$STAGE" + cp -R "$APP" "$STAGE/" + ln -s /Applications "$STAGE/Applications" # drag-to-install, the usual way + hdiutil create -volname "$APP_NAME $VERSION" -srcfolder "$STAGE" \ + -ov -format UDZO -quiet "$DMG" + rm -rf "$STAGE" + [[ "$IDENTITY" == "-" ]] || codesign --force --sign "$IDENTITY" --timestamp "$DMG" + echo "built $DMG ($(du -sh "$DMG" | awk '{print $1}'))" + + if [[ $NOTARIZE -eq 1 ]]; then + if [[ "$IDENTITY" == "-" ]]; then + echo "build.sh: can't notarise an ad-hoc signed app — a Developer ID is required" >&2 + exit 1 + fi + PROFILE="${NOTARY_PROFILE:-proddash}" + echo " notarising as keychain profile \"$PROFILE\" (a few minutes)" + xcrun notarytool submit "$DMG" --keychain-profile "$PROFILE" --wait + xcrun stapler staple "$DMG" + echo " notarised and stapled — this opens anywhere with no warning" + fi +fi 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" @@ -109,6 +218,5 @@ if [[ $INSTALL -eq 1 ]]; then APP="$DEST" fi -if [[ $RUN -eq 1 ]]; then - open "$APP" -fi +[[ $RUN -eq 1 ]] && open "$APP" +exit 0