diff --git a/.gitignore b/.gitignore index 542e8b2..1e30fc9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ config/layouts/ # compiled per-machine, source sits next to it modules/propresenter-timers/ltc-capture + +# 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 b5670ff..ba8b906 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,11 +3,15 @@ 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 — 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. +[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..80696b3 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,27 @@ and a clear degraded state, then recover on their own. ## Run it +### macOS — the app + +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 --dmg +``` + +### Anywhere else, or from a checkout + Requires [Node.js](https://nodejs.org) 18 or newer. ```bash @@ -67,13 +88,16 @@ 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: | 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..5497829 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 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. + ## 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..9ea0e6d --- /dev/null +++ b/launcher/macos/README.md @@ -0,0 +1,180 @@ +# ProdDash for macOS + +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. + +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. + +## What's in the bundle + +``` +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 +``` + +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. 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 +./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 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. + +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 + +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 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 + +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 + +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, + 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. 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. + +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. +- **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 | +| --- | --- | +| "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 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 new file mode 100644 index 0000000..9108e0b --- /dev/null +++ b/launcher/macos/Resources/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleName + ProdDash + CFBundleDisplayName + ProdDash + CFBundleExecutable + ProdDash + CFBundleIdentifier + org.waterschurch.proddash + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1.0.0 + ProdDashLauncherVersion + 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/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 new file mode 100644 index 0000000..5c6cf81 --- /dev/null +++ b/launcher/macos/Sources/AppDelegate.swift @@ -0,0 +1,335 @@ +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 { + + /// 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?["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") + + 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) + 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: - 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. + 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?, dataDir: URL) -> 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 } + } + // 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() + 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)) } + // 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"] + .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, 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)") } + 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) + } + } 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("Network") { + HStack { + TextField("Port", text: $portText) + .frame(width: 90) + .onSubmit(applyPort) + Button("Apply", action: applyPort) + .disabled(Int(portText) == server.port || Int(portText) == nil) + Spacer() + } + } + + Section("Startup") { + 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, action: nil) + 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 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..e391b23 --- /dev/null +++ b/launcher/macos/Sources/Permissions.swift @@ -0,0 +1,324 @@ +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 + + 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 + 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 + LogStore.shared.launcher("local network check: nothing configured to try yet") + } + 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 + let verdict: String + if answered == results.count { + verdict = "every configured host answered" + } else if answered == 0 { + verdict = "nothing answered — if the gear is on, macOS is withholding local network access" + } else { + verdict = "\(answered) of \(results.count) hosts answered" + } + LogStore.shared.launcher("local network check: \(verdict)") + } + } + + /// 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..f7d10ae --- /dev/null +++ b/launcher/macos/Sources/PermissionsPane.swift @@ -0,0 +1,234 @@ +import AVFoundation +import SwiftUI +import UserNotifications + +/** + The permissions pane: one line per permission — a status dot, its name, and + the one thing you can do about it. + + 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 permissions: PermissionsModel + + var body: some View { + VStack(spacing: 0) { + Form { + Section { + if permissions.needs.isEmpty { + Text("No installed module declares a permission.") + .foregroundStyle(.secondary) + } + ForEach(permissions.needs) { need in + row(for: need) + } + notifications + } + } + .formStyle(.grouped) + + Button("Re-check") { + permissions.refresh(root: server.rootURL, dataDir: server.dataDir) + } + .padding(.bottom, 14) + } + .onAppear { permissions.refresh(root: server.rootURL, dataDir: server.dataDir) } + } + + // MARK: - Rows + + @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 { + switch permissions.microphone { + case .authorized: return .good + case .denied, .restricted: return .bad + default: return .unknown + } + } + + private var microphoneActionTitle: String? { + switch permissions.microphone { + case .authorized: return nil + case .notDetermined: return "Allow" + default: return "Open System Settings" + } + } + + /// 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 { + switch permissions.notifications { + case .authorized, .provisional, .ephemeral: return .good + case .denied: return .bad + 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: - 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 + } + } + + /// 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 PermissionRow: View { + let title: String + let tone: Tone + let help: String + var busy = false + var actionTitle: String? + var action: (() -> Void)? + + var body: some View { + 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 new file mode 100644 index 0000000..f4eabb4 --- /dev/null +++ b/launcher/macos/Sources/ServerController.swift @@ -0,0 +1,428 @@ +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 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() { + // 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 { + 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 = [] + 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) + 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…") + 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..c9d7ecc --- /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. + /// 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 + 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" + static let privacy = "x-apple.systempreferences:com.apple.preference.security?Privacy" +} 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..5bb9b0b --- /dev/null +++ b/launcher/macos/build.sh @@ -0,0 +1,222 @@ +#!/bin/zsh +# Build ProdDash.app — the whole application: launcher, server and its own +# Node runtime, with nothing to install first. +# +# ./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 +# ./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. +# +# 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" +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 ;; + --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 + +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 +} + +# 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 "$CONTENTS/MacOS" "$CONTENTS/Resources" + +# ── 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 + +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" + +SOURCE_ICON="$REPO_ROOT/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 + 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 "$CONTENTS/Resources/AppIcon.icns" + rm -rf "$ICONSET" +fi + +# ── 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" + + 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 + + # 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" + 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 + +[[ $RUN -eq 1 ]] && open "$APP" +exit 0 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",