Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adac3a4785 | ||
|
|
e1bceb3e60 | ||
|
|
1dbd55aa24 | ||
|
|
abc70aa53d | ||
|
|
a50709b6ff | ||
|
|
5ecc39efe2 |
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -168,8 +192,10 @@ below. Nothing is installed without someone clicking.
|
||||
|
||||
- **ProdCom** — live transcript stream with in-place updates of in-progress
|
||||
speech, channel colors, per-tile channel visibility toggles and group
|
||||
presets, timestamps, text size and jump-to-latest in the title bar.
|
||||
presets, timestamps, text size, a top-down / bottom-up direction toggle
|
||||
and jump-to-latest in the title bar.
|
||||
Admin config: ProdCom server (host : port), API key.
|
||||
Tile settings: channel icons, direction and timestamps.
|
||||
- **ProPresenter** — what's live and what's next, with slide progress,
|
||||
driven by the shared `propresenter-core` client (ProPresenter 7/20).
|
||||
Admin config: ProPresenter endpoint (host : port), password.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-<version>-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
|
||||
(`<name>.swift` beside a module's files becomes `<name>`), 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-<date>.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. |
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>ProdDash</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>ProdDash</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>ProdDash</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.waterschurch.proddash</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0.0</string>
|
||||
<key>ProdDashLauncherVersion</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.utilities</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<!-- Menu-bar app: no Dock icon, no app menu. -->
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<!-- The prompts this app exists to own. macOS shows these sentences
|
||||
verbatim, so they say what ProdDash does with each permission. -->
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>ProdDash reads an audio input on this Mac to decode SMPTE LTC timecode for the Timers module.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>ProdDash connects to production gear on this network — ProdCom, ProPresenter and anything else its modules are pointed at.</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_http._tcp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- The launcher asks for microphone access on the modules' behalf; under
|
||||
the hardened runtime that needs saying out loud. -->
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Module helpers that read an audio input — ltc-capture, and anything
|
||||
like it a later module ships. -->
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- V8 compiles JavaScript as it runs; without these the hardened runtime
|
||||
kills node on its first optimisation. -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<!-- A module may ship a native addon that nobody signed with our identity. -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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<AnyCancellable>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<CChar>?] = ([executable] + arguments).map { strdup($0) } + [nil]
|
||||
let envp: [UnsafeMutablePointer<CChar>?] = 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..<nl)
|
||||
buffer.removeSubrange(buffer.startIndex...nl)
|
||||
lines.append(String(data: raw, encoding: .utf8) ?? "")
|
||||
}
|
||||
if lines.isEmpty { return }
|
||||
DispatchQueue.main.async { for line in lines { onLine(line, isError) } }
|
||||
}
|
||||
}
|
||||
|
||||
private func releasePipes() {
|
||||
for handle in [outPipe.fileHandleForReading, errPipe.fileHandleForReading] {
|
||||
handle.readabilityHandler = nil
|
||||
try? handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stopping
|
||||
|
||||
var isAlive: Bool { kill(pid, 0) == 0 }
|
||||
|
||||
/// SIGTERM to the whole group. The server unmounts its modules and exits
|
||||
/// within ~1.5 s; `forceKill()` is the backstop if it doesn't.
|
||||
func terminate() {
|
||||
guard !terminated else { return }
|
||||
terminated = true
|
||||
signalGroup(SIGTERM)
|
||||
}
|
||||
|
||||
func forceKill() {
|
||||
signalGroup(SIGKILL)
|
||||
}
|
||||
|
||||
private func signalGroup(_ sig: Int32) {
|
||||
// Only address the group when the child really leads one — otherwise a
|
||||
// negative pid could reach processes that are none of our business.
|
||||
if getpgid(pid) == pid {
|
||||
kill(-pid, sig)
|
||||
} else {
|
||||
kill(pid, sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Pipe {
|
||||
func closeBothEnds() {
|
||||
try? fileHandleForReading.close()
|
||||
try? fileHandleForWriting.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
Where things are, and which settings belong to whom.
|
||||
|
||||
Two kinds of setting live in this app, and they are deliberately kept apart:
|
||||
|
||||
* **ProdDash settings** (the port) belong to ProdDash, so they are written
|
||||
where ProdDash already looks for them — `proddash.json` in the per-machine
|
||||
data directory. Start the server any other way (`Start ProdDash.command`,
|
||||
a terminal) and it still comes up on the port set here.
|
||||
* **Launcher settings** (auto-start, restart-on-crash, where the checkout
|
||||
lives) belong to this app and live in its own preferences.
|
||||
|
||||
The data-directory and port rules mirror server.js exactly — if that file's
|
||||
`resolveDataDir` / `PORT` logic changes, change it here too.
|
||||
*/
|
||||
enum ProdDash {
|
||||
|
||||
// MARK: - Data directory (mirrors server.js resolveDataDir)
|
||||
|
||||
/// `PRODDASH_DATA_DIR` if the environment sets one (relative paths resolve
|
||||
/// against the checkout, as in server.js), else the per-machine default.
|
||||
static func dataDir(root: URL?) -> URL {
|
||||
let fromEnv = (ProcessInfo.processInfo.environment["PRODDASH_DATA_DIR"] ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !fromEnv.isEmpty {
|
||||
if fromEnv.hasPrefix("/") { return URL(fileURLWithPath: fromEnv) }
|
||||
if let root { return root.appendingPathComponent(fromEnv).standardizedFileURL }
|
||||
}
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/ProdDash")
|
||||
}
|
||||
|
||||
// MARK: - 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..<max(lhs.count, rhs.count) {
|
||||
let l = i < lhs.count ? lhs[i] : 0
|
||||
let r = i < rhs.count ? rhs[i] : 0
|
||||
if l != r { return l < r ? -1 : 1 }
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
|
||||
/// The launcher's own preferences — everything that is about *running* ProdDash
|
||||
/// rather than about ProdDash itself. (The port is not here: it belongs to
|
||||
/// ProdDash and is written to its proddash.json — see LauncherConfig.)
|
||||
final class LauncherSettings: ObservableObject {
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
|
||||
@Published var rootPath: String { didSet { defaults.set(rootPath, forKey: "rootPath") } }
|
||||
@Published var nodePath: String { didSet { defaults.set(nodePath, forKey: "nodePath") } }
|
||||
/// Start the server as soon as the launcher opens (so a login item brings the
|
||||
/// whole dashboard up by itself).
|
||||
@Published var startServerAtLaunch: Bool { didSet { defaults.set(startServerAtLaunch, forKey: "startServerAtLaunch") } }
|
||||
/// Bring the server back if it dies on its own. Backs off, and gives up
|
||||
/// after a burst of fast crashes rather than looping forever.
|
||||
@Published var restartOnCrash: Bool { didSet { defaults.set(restartOnCrash, forKey: "restartOnCrash") } }
|
||||
/// Open the dashboard in a browser whenever the server comes up.
|
||||
@Published var openDashboardOnStart: Bool { didSet { defaults.set(openDashboardOnStart, forKey: "openDashboardOnStart") } }
|
||||
/// Show this window at launch. Off means the launcher starts straight into
|
||||
/// the menu bar (Companion's "start minimised").
|
||||
@Published var showWindowAtLaunch: Bool { didSet { defaults.set(showWindowAtLaunch, forKey: "showWindowAtLaunch") } }
|
||||
|
||||
init() {
|
||||
defaults.register(defaults: [
|
||||
"startServerAtLaunch": true,
|
||||
"restartOnCrash": true,
|
||||
"openDashboardOnStart": false,
|
||||
"showWindowAtLaunch": true,
|
||||
])
|
||||
rootPath = defaults.string(forKey: "rootPath") ?? ""
|
||||
nodePath = defaults.string(forKey: "nodePath") ?? ""
|
||||
startServerAtLaunch = defaults.bool(forKey: "startServerAtLaunch")
|
||||
restartOnCrash = defaults.bool(forKey: "restartOnCrash")
|
||||
openDashboardOnStart = defaults.bool(forKey: "openDashboardOnStart")
|
||||
showWindowAtLaunch = defaults.bool(forKey: "showWindowAtLaunch")
|
||||
}
|
||||
|
||||
/// False until the launcher has run once — used to open the window (and ask
|
||||
/// for the permissions ProdDash needs) the first time only.
|
||||
var hasRunBefore: Bool {
|
||||
get { defaults.bool(forKey: "hasRunBefore") }
|
||||
set { defaults.set(newValue, forKey: "hasRunBefore") }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import AVFoundation
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
|
||||
/**
|
||||
The launcher window: what ProdDash is doing at the top, and the three things
|
||||
an operator ever needs underneath — what it is saying (Log), how it runs
|
||||
(Settings), and what macOS is letting it do (Permissions).
|
||||
*/
|
||||
struct LauncherView: View {
|
||||
|
||||
@ObservedObject var server: ServerController
|
||||
@ObservedObject var settings: LauncherSettings
|
||||
@ObservedObject var permissions: PermissionsModel
|
||||
let onHide: () -> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}()
|
||||
}
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
@@ -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<String>()
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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..<colon].trimmingCharacters(in: .whitespaces)
|
||||
let value = text[text.index(after: colon)...].trimmingCharacters(in: .whitespaces)
|
||||
switch field {
|
||||
case "Local": localURL = value
|
||||
case "Network": if !networkURLs.contains(value) { networkURLs.append(value) }
|
||||
case "Settings": settingsDir = value
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Exit
|
||||
|
||||
private func handleExit(code: Int32, bySignal: Bool) {
|
||||
stopHealthPolling()
|
||||
child = nil
|
||||
healthy = false
|
||||
let ranFor = startedAt.map { Date().timeIntervalSince($0) } ?? 0
|
||||
if ranFor > 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) }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
Executable
+222
@@ -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-<version>-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: <name>.swift beside a module's files becomes <name>.
|
||||
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
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Ported from prodcom-listener/public/app.js into the ProdDash module
|
||||
contract: everything lives inside the tile's root, per-tile state
|
||||
(hidden channels, timestamps, text size) persists in instance settings,
|
||||
and all network traffic goes through moduleApi (the module's server
|
||||
proxy at /prodcom/*). */
|
||||
(hidden channels, channel icons, timestamps, text size, flow direction)
|
||||
persists in instance settings, and all network traffic goes through
|
||||
moduleApi (the module's server proxy at /prodcom/*). */
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
/* ── per-instance state ─────────────────────────────────────────── */
|
||||
@@ -12,7 +12,7 @@ export default function create({ root, moduleApi }) {
|
||||
const channels = new Map(); // channelId -> {name, color}
|
||||
const groups = new Map(); // groupId -> {name, channelIds}
|
||||
const entryEls = new Map(); // entryId -> element
|
||||
let pinnedToBottom = true;
|
||||
let pinnedToLatest = true;
|
||||
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
|
||||
let stream = null; // moduleApi.sse handle
|
||||
let bootTimer = null;
|
||||
@@ -40,10 +40,14 @@ export default function create({ root, moduleApi }) {
|
||||
const CLOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
|
||||
const JUMP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>';
|
||||
const CLEAR_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></svg>';
|
||||
const CHANNELS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16l-6 7.4V19l-4 2v-8.6z"/></svg>';
|
||||
// arrow shows where new messages land: down = oldest first, up = newest first
|
||||
const FLOW_DOWN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4v14"/><path d="m3 15 3 3 3-3"/><path d="M12 5h8"/><path d="M12 12h8"/><path d="M12 19h6"/></svg>';
|
||||
const FLOW_UP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 20V6"/><path d="m3 9 3-3 3 3"/><path d="M12 5h8"/><path d="M12 12h8"/><path d="M12 19h6"/></svg>';
|
||||
|
||||
let channelMenuEl = null; // the open menu's element (filled on each open)
|
||||
const channelsMenu = moduleApi.header.addMenu({
|
||||
label: 'Channels ▾',
|
||||
icon: CHANNELS_SVG,
|
||||
title: 'Choose visible channels',
|
||||
build(menu) {
|
||||
menu.classList.add('pt-menu');
|
||||
@@ -52,6 +56,11 @@ export default function create({ root, moduleApi }) {
|
||||
},
|
||||
});
|
||||
channelsMenu.button.classList.add('pt-channels-btn');
|
||||
// the icon carries the label; a count rides alongside it while filtering
|
||||
const channelCountEl = document.createElement('span');
|
||||
channelCountEl.className = 'pt-count';
|
||||
channelCountEl.hidden = true;
|
||||
channelsMenu.button.appendChild(channelCountEl);
|
||||
|
||||
let timeBtn = null;
|
||||
timeBtn = moduleApi.header.addButton({
|
||||
@@ -71,13 +80,24 @@ export default function create({ root, moduleApi }) {
|
||||
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
||||
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(2) });
|
||||
|
||||
// icon and tooltip track the current direction — applyInstanceSettings sets both
|
||||
const flowBtn = moduleApi.header.addButton({
|
||||
icon: FLOW_DOWN_SVG,
|
||||
onClick() {
|
||||
moduleApi.saveInstanceSettings({ newestFirst: !moduleApi.instanceSettings.newestFirst });
|
||||
applyInstanceSettings();
|
||||
// the two directions have opposite "latest" ends — follow the flip
|
||||
if (pinnedToLatest) scrollToLatest();
|
||||
},
|
||||
});
|
||||
|
||||
moduleApi.header.addButton({
|
||||
icon: JUMP_SVG,
|
||||
title: 'Jump to latest',
|
||||
onClick() {
|
||||
pinnedToBottom = true;
|
||||
pinnedToLatest = true;
|
||||
jumpBtn.hidden = true;
|
||||
scrollToBottom();
|
||||
scrollToLatest();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -93,25 +113,20 @@ export default function create({ root, moduleApi }) {
|
||||
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
||||
wrap.style.setProperty('--pt-size', size + 'px');
|
||||
wrap.classList.toggle('show-times', Boolean(s.showTimes));
|
||||
wrap.classList.toggle('hide-avatars', s.showAvatars === false);
|
||||
timeBtn?.classList.toggle('active', Boolean(s.showTimes));
|
||||
|
||||
const up = newestFirst();
|
||||
wrap.classList.toggle('newest-first', up);
|
||||
flowBtn.innerHTML = up ? FLOW_UP_SVG : FLOW_DOWN_SVG;
|
||||
flowBtn.classList.toggle('active', up);
|
||||
flowBtn.title = up
|
||||
? 'Newest at top — click for newest at bottom'
|
||||
: 'Newest at bottom — click for newest at top';
|
||||
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
|
||||
}
|
||||
applyInstanceSettings();
|
||||
|
||||
/* ── channel filter (instance setting) ──────────────────────────── */
|
||||
|
||||
function channelFilter() {
|
||||
return String(moduleApi.instanceSettings.channel || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** Entry hidden by the hard per-tile filter (settings.channel)? */
|
||||
function filteredOut(channelId, channelName) {
|
||||
const want = channelFilter();
|
||||
if (!want) return false;
|
||||
const known = channels.get(channelId);
|
||||
const name = ((known && known.name) || channelName || '').toLowerCase();
|
||||
return name !== want && String(channelId).toLowerCase() !== want;
|
||||
}
|
||||
|
||||
/* ── channel visibility toggles ─────────────────────────────────── */
|
||||
|
||||
function persistHidden() {
|
||||
@@ -119,9 +134,7 @@ export default function create({ root, moduleApi }) {
|
||||
}
|
||||
|
||||
function applyFilter(el) {
|
||||
const hidden = hiddenChannels.has(el.dataset.channelId)
|
||||
|| filteredOut(el.dataset.channelId, el.dataset.channelName);
|
||||
el.classList.toggle('hidden-by-filter', hidden);
|
||||
el.classList.toggle('hidden-by-filter', hiddenChannels.has(el.dataset.channelId));
|
||||
}
|
||||
|
||||
function toggleChannel(id) {
|
||||
@@ -130,7 +143,7 @@ export default function create({ root, moduleApi }) {
|
||||
persistHidden();
|
||||
rebuildChannelMenu();
|
||||
entryEls.forEach((el) => applyFilter(el));
|
||||
if (pinnedToBottom) scrollToBottom();
|
||||
if (pinnedToLatest) scrollToLatest();
|
||||
}
|
||||
|
||||
/** Show exactly this group's channels, hide the rest */
|
||||
@@ -139,7 +152,7 @@ export default function create({ root, moduleApi }) {
|
||||
persistHidden();
|
||||
rebuildChannelMenu();
|
||||
entryEls.forEach((el) => applyFilter(el));
|
||||
if (pinnedToBottom) scrollToBottom();
|
||||
if (pinnedToLatest) scrollToLatest();
|
||||
}
|
||||
|
||||
function groupIsActive(g) {
|
||||
@@ -150,18 +163,24 @@ export default function create({ root, moduleApi }) {
|
||||
);
|
||||
}
|
||||
|
||||
function updateChannelsLabel() {
|
||||
/* The button is an icon, so the "some channels are hidden" state shows as
|
||||
an accent tint plus a compact count beside it. */
|
||||
function updateChannelsButton() {
|
||||
let visibleCount = 0;
|
||||
for (const id of channels.keys()) {
|
||||
if (!hiddenChannels.has(id)) visibleCount++;
|
||||
}
|
||||
channelsMenu.setLabel(visibleCount === channels.size
|
||||
? 'Channels ▾'
|
||||
: `Channels (${visibleCount}/${channels.size}) ▾`);
|
||||
const filtering = channels.size > 0 && visibleCount !== channels.size;
|
||||
channelCountEl.textContent = filtering ? `${visibleCount}/${channels.size}` : '';
|
||||
channelCountEl.hidden = !filtering;
|
||||
channelsMenu.button.classList.toggle('active', filtering);
|
||||
channelsMenu.button.title = filtering
|
||||
? `Channels — showing ${visibleCount} of ${channels.size}`
|
||||
: 'Choose visible channels';
|
||||
}
|
||||
|
||||
function rebuildChannelMenu() {
|
||||
updateChannelsLabel();
|
||||
updateChannelsButton();
|
||||
const channelMenu = channelMenuEl;
|
||||
if (!channelMenu) return; // menu not opened yet — the label is enough
|
||||
channelMenu.innerHTML = '';
|
||||
@@ -225,25 +244,33 @@ export default function create({ root, moduleApi }) {
|
||||
|
||||
/* ── scrolling ──────────────────────────────────────────────────── */
|
||||
|
||||
/** Newest entry at the top (bottom-up) instead of at the bottom? */
|
||||
function newestFirst() {
|
||||
return Boolean(moduleApi.instanceSettings.newestFirst);
|
||||
}
|
||||
|
||||
streamEl.addEventListener('scroll', () => {
|
||||
const nearBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
|
||||
pinnedToBottom = nearBottom;
|
||||
if (nearBottom) jumpBtn.hidden = true;
|
||||
const near = newestFirst()
|
||||
? streamEl.scrollTop < 60
|
||||
: streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
|
||||
pinnedToLatest = near;
|
||||
if (near) jumpBtn.hidden = true;
|
||||
});
|
||||
|
||||
jumpBtn.addEventListener('click', () => {
|
||||
pinnedToBottom = true;
|
||||
pinnedToLatest = true;
|
||||
jumpBtn.hidden = true;
|
||||
scrollToBottom();
|
||||
scrollToLatest();
|
||||
});
|
||||
|
||||
function scrollToBottom() {
|
||||
streamEl.scrollTop = streamEl.scrollHeight;
|
||||
/** The latest entry is at the top when flipped, at the bottom otherwise. */
|
||||
function scrollToLatest() {
|
||||
streamEl.scrollTop = newestFirst() ? 0 : streamEl.scrollHeight;
|
||||
}
|
||||
|
||||
function afterInsert(el) {
|
||||
if (el.classList.contains('hidden-by-filter')) return;
|
||||
if (pinnedToBottom) scrollToBottom();
|
||||
if (pinnedToLatest) scrollToLatest();
|
||||
else jumpBtn.hidden = false;
|
||||
}
|
||||
|
||||
@@ -272,7 +299,6 @@ export default function create({ root, moduleApi }) {
|
||||
el = document.createElement('div');
|
||||
el.className = 'pt-entry';
|
||||
el.dataset.channelId = entry.channelId || '';
|
||||
el.dataset.channelName = entry.channelName || '';
|
||||
const info = channelInfo(entry);
|
||||
el.style.setProperty('--ch', info.color);
|
||||
|
||||
@@ -314,7 +340,7 @@ export default function create({ root, moduleApi }) {
|
||||
afterInsert(el);
|
||||
} else {
|
||||
updateEntryEl(el, entry);
|
||||
if (pinnedToBottom && !el.classList.contains('hidden-by-filter')) scrollToBottom();
|
||||
if (pinnedToLatest && !el.classList.contains('hidden-by-filter')) scrollToLatest();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,7 +456,7 @@ export default function create({ root, moduleApi }) {
|
||||
bootTimer = setTimeout(boot, 5000);
|
||||
return;
|
||||
}
|
||||
scrollToBottom();
|
||||
scrollToLatest();
|
||||
connect();
|
||||
}
|
||||
|
||||
@@ -445,7 +471,7 @@ export default function create({ root, moduleApi }) {
|
||||
root.innerHTML = ''; // header controls are removed by the shell
|
||||
},
|
||||
onResize() {
|
||||
if (pinnedToBottom) scrollToBottom();
|
||||
if (pinnedToLatest) scrollToLatest();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"id": "prodcom-transcript",
|
||||
"name": "ProdCom",
|
||||
"version": "1.0.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",
|
||||
@@ -22,15 +25,15 @@
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"label": "Channel filter (channel name; empty = all)",
|
||||
"default": ""
|
||||
"showAvatars": {
|
||||
"type": "boolean",
|
||||
"label": "Show channel icons",
|
||||
"default": true
|
||||
},
|
||||
"textSize": {
|
||||
"type": "number",
|
||||
"label": "Text size (px)",
|
||||
"default": 15
|
||||
"newestFirst": {
|
||||
"type": "boolean",
|
||||
"label": "Newest first (transcript reads bottom-up)",
|
||||
"default": false
|
||||
},
|
||||
"showTimes": {
|
||||
"type": "boolean",
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── channels button ──
|
||||
Icon-only; the count appears next to it while some channels are hidden. */
|
||||
|
||||
[data-module="prodcom-transcript"] .pt-channels-btn { gap: 3px; }
|
||||
[data-module="prodcom-transcript"] .pt-count {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
|
||||
|
||||
/* ── channels menu ──
|
||||
The menu container is created and positioned by the shell (.tile-menu);
|
||||
it lives inside the tile, so [data-module] scoping still applies. */
|
||||
@@ -113,6 +124,7 @@
|
||||
background: color-mix(in srgb, var(--ch, var(--muted)) 17%, transparent);
|
||||
}
|
||||
[data-module="prodcom-transcript"] .pt-entry .avatar svg { width: 66%; height: 66%; display: block; }
|
||||
[data-module="prodcom-transcript"] .hide-avatars .pt-entry .avatar { display: none; }
|
||||
|
||||
[data-module="prodcom-transcript"] .pt-entry .body { flex: 1; min-width: 0; }
|
||||
|
||||
@@ -143,6 +155,12 @@
|
||||
|
||||
[data-module="prodcom-transcript"] .pt-entry.hidden-by-filter { display: none; }
|
||||
|
||||
/* bottom-up: newest entry at the top, the stream reading upwards */
|
||||
[data-module="prodcom-transcript"] .newest-first .pt-entries {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
/* ── jump-to-latest ── */
|
||||
|
||||
[data-module="prodcom-transcript"] .pt-jump {
|
||||
@@ -163,3 +181,9 @@
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
[data-module="prodcom-transcript"] .pt-jump[hidden] { display: none; }
|
||||
|
||||
/* flipped, "latest" is the top of the stream — so is the pill */
|
||||
[data-module="prodcom-transcript"] .newest-first .pt-jump {
|
||||
top: 12px;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user