Compare commits
72
Commits
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "start",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"run",
|
||||||
|
"start"
|
||||||
|
],
|
||||||
|
"port": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -7,3 +7,10 @@ dist/
|
|||||||
# runtime state written by the admin page / layout saves
|
# runtime state written by the admin page / layout saves
|
||||||
config/modules.json
|
config/modules.json
|
||||||
config/layouts/
|
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,8 +3,15 @@
|
|||||||
Modular production dashboard web app for Waters Church, developed in this repo.
|
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
|
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
|
(`server.js`, Node 18+), dashboard shell client in `public/`, admin page at
|
||||||
`/admin`, modules in `modules/`. [SPEC.md](SPEC.md) was the authority for the
|
`/admin`, modules in `modules/`, and a native macOS menu-bar launcher in
|
||||||
initial build; [README.md](README.md) has run/configure/use instructions.
|
`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
|
## Building a new module — read docs/MODULE-GUIDE.md first
|
||||||
|
|
||||||
@@ -13,8 +20,16 @@ contract** (manifest, client factory + moduleApi, optional server entry,
|
|||||||
title-bar controls). The rules that matter most:
|
title-bar controls). The rules that matter most:
|
||||||
|
|
||||||
- A module is one self-contained folder in `modules/` — **never modify the
|
- A module is one self-contained folder in `modules/` — **never modify the
|
||||||
shell** (`server.js`, `public/`) to add one. Installation = drop the folder,
|
shell** (`server.js`, `public/`) to add one. Installation = the admin page's
|
||||||
restart, configure in `/admin`.
|
Available modules (downloads from the repo into the data directory), or
|
||||||
|
drop the folder and restart; configure in `/admin`.
|
||||||
|
- **All settings in the admin page** (`configSchema` / `instanceSchema`) —
|
||||||
|
never a separate setup page, hand-edited file or env var. **Dependencies
|
||||||
|
ship inside the module folder** (zero deps is the norm). **Everything the
|
||||||
|
module does runs inside its server entry** — no helper scripts, companion
|
||||||
|
programs or daemons the operator starts by hand; a listener or decoder is
|
||||||
|
started in `init()` and stopped in `stop()`. Declare `"proddash": ">=x.y.z"`
|
||||||
|
and bump the module's `version` on every change.
|
||||||
- Browsers never call upstream services directly; the module's server routes
|
- Browsers never call upstream services directly; the module's server routes
|
||||||
proxy everything (no CORS, secrets stay server-side).
|
proxy everything (no CORS, secrets stay server-side).
|
||||||
- Style with the shell's CSS variables only, scoped under
|
- Style with the shell's CSS variables only, scoped under
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ touching the shell (see [docs/MODULE-GUIDE.md](docs/MODULE-GUIDE.md)).
|
|||||||
that's where module servers proxy ProdCom, poll ProPresenter, and stream
|
that's where module servers proxy ProdCom, poll ProPresenter, and stream
|
||||||
live updates over SSE, so browsers never talk to production gear
|
live updates over SSE, so browsers never talk to production gear
|
||||||
directly (no CORS, and API keys never leave the server).
|
directly (no CORS, and API keys never leave the server).
|
||||||
4. Stores server-wide module config and named layouts under `config/`.
|
4. Stores server-wide module config and named layouts in a per-machine data
|
||||||
|
directory outside the app folder, so updating ProdDash never loses them.
|
||||||
|
|
||||||
Each browser arranges its own tiles (add, drag, resize, remove, per-tile
|
Each browser arranges its own tiles (add, drag, resize, remove, per-tile
|
||||||
settings); the layout autosaves to that browser's `localStorage`. Named
|
settings); the layout autosaves to that browser's `localStorage`. Named
|
||||||
@@ -28,6 +29,27 @@ and a clear degraded state, then recover on their own.
|
|||||||
|
|
||||||
## Run it
|
## 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.
|
Requires [Node.js](https://nodejs.org) 18 or newer.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -47,28 +69,133 @@ enable/disable) live in the **admin page** — open **Admin** from the
|
|||||||
dashboard's top bar, or go to `/admin`. Saving re-initialises the module
|
dashboard's top bar, or go to `/admin`. Saving re-initialises the module
|
||||||
immediately; open tiles reconnect on their own.
|
immediately; open tiles reconnect on their own.
|
||||||
|
|
||||||
Shell settings live in [config/proddash.json](config/proddash.json):
|
### Where settings are kept
|
||||||
|
|
||||||
|
Everything you enter in the admin page (module connection settings, API keys,
|
||||||
|
enabled/disabled flags) and every named layout is written to a **data
|
||||||
|
directory on the machine running the server** — not into the app folder:
|
||||||
|
|
||||||
|
| OS | Data directory |
|
||||||
|
| --- | --- |
|
||||||
|
| Windows | `%APPDATA%\ProdDash` (e.g. `C:\Users\<you>\AppData\Roaming\ProdDash`) |
|
||||||
|
| macOS | `~/Library/Application Support/ProdDash` |
|
||||||
|
| Linux | `$XDG_CONFIG_HOME/proddash`, else `~/.config/proddash` |
|
||||||
|
|
||||||
|
Because it lives outside the checkout, you can `git pull`, re-clone, or reset
|
||||||
|
the app folder from `main` and start the new version with everything still
|
||||||
|
configured. The admin page shows the exact path in use, and the server prints
|
||||||
|
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. 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 |
|
| 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. |
|
| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. |
|
||||||
|
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
|
||||||
|
|
||||||
Environment variables `PORT` and `PRODDASH_PASSCODE` override the file.
|
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
|
||||||
`config/modules.json` (module config) and `config/layouts/` (named layouts)
|
`PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path
|
||||||
are written by the app itself.
|
is taken from the app folder; `PRODDASH_DATA_DIR=config` restores the old
|
||||||
|
in-repo location). If the default directory can't be created, the server says
|
||||||
|
so and falls back to the app's `config/` folder.
|
||||||
|
|
||||||
|
**Upgrading from an earlier version:** the first start copies an existing
|
||||||
|
`config/modules.json` and `config/layouts/` from the app folder into the data
|
||||||
|
directory, so nothing has to be re-entered. The old files are left in place and
|
||||||
|
ignored from then on.
|
||||||
|
|
||||||
|
### Themes
|
||||||
|
|
||||||
|
The whole dashboard — every tile, on every browser — draws with one palette,
|
||||||
|
chosen in the admin page's **Theme** section and switched live: pick a swatch
|
||||||
|
and open dashboards change without a reload. Five presets ship: **Booth**
|
||||||
|
(the default dark, mint accent), **Harbor** (deep navy, cyan), **Graphite**
|
||||||
|
(neutral charcoal, electric blue), **Ember** (warm broadcast black, orange)
|
||||||
|
and **Daylight**, a light palette for a front-of-house desk in daytime. Each
|
||||||
|
keeps the status colours apart — the accent is never near the danger red or
|
||||||
|
the warning amber — and muted text readable in a dark booth. The choice is
|
||||||
|
stored as `theme` in the data directory's `proddash.json` (table above), so
|
||||||
|
it holds across restarts and updates; a browser remembers the last theme it
|
||||||
|
saw and applies it before the server answers, so nothing flashes. Modules
|
||||||
|
style with the shell's colour variables, which is why they follow; a colour
|
||||||
|
set on a tile itself (a PCO Plan header colour, say) is deliberately kept.
|
||||||
|
|
||||||
|
## Installing modules and updating
|
||||||
|
|
||||||
|
Everything below happens in the **admin page**; ProdDash talks to its GitHub
|
||||||
|
repository (`michaelmcgary2008/ProdDash`, branch `main` — change with
|
||||||
|
`repo` / `branch` in `proddash.json` or the `PRODDASH_REPO` variable,
|
||||||
|
`owner/repo#branch`). It downloads one small archive of the branch, at most
|
||||||
|
every few minutes, only while the admin page is open or for the update check
|
||||||
|
below. Nothing is installed without someone clicking.
|
||||||
|
|
||||||
|
- **Available modules** lists modules in the repo that aren't installed
|
||||||
|
here. **Install** downloads the module into the data directory's `modules/`
|
||||||
|
and starts it right away — nothing is written into the app folder, and no
|
||||||
|
restart is needed. Modules that ship with your copy of ProdDash are
|
||||||
|
"bundled"; ones you installed are "installed"; if both exist, the newer
|
||||||
|
version runs.
|
||||||
|
- **Uninstall** on a module card removes an installed module's files. For a
|
||||||
|
bundled module (its folder belongs to the app) it hides the module from
|
||||||
|
every dashboard instead; it reappears under Available modules and can be
|
||||||
|
brought back without downloading. Settings are kept either way.
|
||||||
|
- **Module updates**: when the repo has a newer version of an installed
|
||||||
|
module, its card shows **Update to vX**.
|
||||||
|
- **Version requirements**: every module declares the ProdDash versions it
|
||||||
|
needs (`"proddash": ">=1.1.0"` in its manifest). A module that needs a
|
||||||
|
newer shell is listed but not loaded, and cannot be installed until
|
||||||
|
ProdDash is updated.
|
||||||
|
- **ProdDash updates**: the admin page checks the repo shortly after start
|
||||||
|
and every six hours, and shows a banner when `main` has a newer version
|
||||||
|
(or new commits). **Update now** brings the app folder up to date and
|
||||||
|
restarts the server; dashboards reconnect on their own. A git checkout is
|
||||||
|
updated with `git pull --ff-only` (only when it is on the repo branch with
|
||||||
|
no local changes — otherwise the banner says why and you update with git).
|
||||||
|
A plain folder (no `.git`) has the repo archive unpacked over it; files
|
||||||
|
removed upstream are left behind but harmless. Settings and layouts are
|
||||||
|
never touched. **What changed** opens the commit comparison on GitHub.
|
||||||
|
- **Restarting**: after an update the server exits with code 75 and starts
|
||||||
|
its own replacement, which waits for the port to free up. If you supervise
|
||||||
|
ProdDash with your own launcher, set `PRODDASH_LAUNCHER=1` so the server
|
||||||
|
just exits and your launcher restarts it on exit code 75.
|
||||||
|
`PRODDASH_NO_REMOTE_CHECK=1` turns off the periodic check (the admin page
|
||||||
|
still checks when opened).
|
||||||
|
|
||||||
## Using the dashboard
|
## Using the dashboard
|
||||||
|
|
||||||
- **+** — add any enabled module; multiple tiles of the same module are
|
- **+** — add any enabled module; multiple tiles of the same module are
|
||||||
fine (e.g. two transcripts filtered to different channels).
|
fine (e.g. two transcripts filtered to different channels).
|
||||||
- Drag a tile by its **title bar** to move it; drag the **corner handle**
|
- Drag a tile by its **title bar** to move it; drag either **bottom
|
||||||
to resize; **✕** removes it. Tiles sit flush — when two share an edge,
|
corner** to resize; **✕** removes it. Hover a tile and a small pill
|
||||||
hover the seam and a small grab pill appears; drag it to trade width
|
appears on its **bottom edge** — drag it to change just the height. If
|
||||||
between the two tiles (macOS split-view style).
|
another tile sits directly below, the pill moves the shared edge instead,
|
||||||
|
trading rows between the two so the column keeps its total height. Tiles
|
||||||
|
sit flush — when two share a vertical edge, hover the seam and the same
|
||||||
|
kind of pill appears; drag it to trade width (macOS split-view style).
|
||||||
|
- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its
|
||||||
|
green lip at the top-left brings it back, minus the Admin button), the
|
||||||
|
occupied columns stretch to the full width, and every column runs to the
|
||||||
|
bottom of the screen — the last tile in each column is stretched to end
|
||||||
|
there. A short tile like the clock is never stretched into a ribbon: under
|
||||||
|
another tile it slides to the bottom edge and the tile above grows into the
|
||||||
|
gap; alone in its columns it stays as it is. Nothing is saved — the layout
|
||||||
|
reverts when you leave fullscreen. A layout deeper than the screen keeps
|
||||||
|
its normal row height and scrolls.
|
||||||
- A tile's actions (menus, text size, clear…) live in its **title bar**;
|
- A tile's actions (menus, text size, clear…) live in its **title bar**;
|
||||||
the small **notch** on the bar's bottom edge hides/shows the whole bar
|
the small **notch** on the bar's bottom edge hides/shows the whole bar
|
||||||
for a clean wall-display look.
|
for a clean wall-display look. The menu bar's handle is the **ProdDash
|
||||||
|
name itself**, wrapped in a translucent green pill at the left — click it
|
||||||
|
to hide the bar; the slim green lip it leaves at the top-left edge brings
|
||||||
|
the bar back. It stays at the left so it never sits on top of a tile's
|
||||||
|
own tab.
|
||||||
- The **gear** on a tile holds per-tile settings (filters, text size,
|
- The **gear** on a tile holds per-tile settings (filters, text size,
|
||||||
display options) — these belong to this browser's layout, not the server.
|
display options) — these belong to this browser's layout, not the server.
|
||||||
- The **grid icon** — save the current arrangement to the server under a
|
- The **grid icon** — save the current arrangement to the server under a
|
||||||
@@ -82,25 +209,56 @@ are written by the app itself.
|
|||||||
|
|
||||||
- **ProdCom** — live transcript stream with in-place updates of in-progress
|
- **ProdCom** — live transcript stream with in-place updates of in-progress
|
||||||
speech, channel colors, per-tile channel visibility toggles and group
|
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
|
||||||
Admin config: ProdCom URL, API key.
|
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,
|
- **ProPresenter** — what's live and what's next, with slide progress,
|
||||||
driven by the shared `propresenter-core` client (ProPresenter 7/20).
|
driven by the shared `propresenter-core` client (ProPresenter 7/20).
|
||||||
Admin config: host, port, password.
|
Admin config: ProPresenter endpoint (host : port), password.
|
||||||
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
||||||
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
|
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
|
||||||
discovered timer (and the LTC card) as its own tile, or add "All timers"
|
discovered timer (and the LTC card) as its own tile, or add "All timers"
|
||||||
and pick a selection via the tile's "Timers ▾" menu; overruns are
|
and pick a selection via the tile's "Timers ▾" menu; overruns are
|
||||||
unmistakably red. Read-only — no start/stop controls. Admin config: host,
|
unmistakably red. Read-only — no start/stop controls. Admin config is in
|
||||||
port, password.
|
three groups: **ProPresenter** (endpoint host : port, API password),
|
||||||
|
**LTC Timecode** (the listener switch, and — when it's on — the audio
|
||||||
|
device and channel), and **Display** (Timer font — Default or Monospace,
|
||||||
|
which keeps the digits from shifting as the numbers change).
|
||||||
|
|
||||||
|
ProPresenter exposes no timecode over any API, so LTC is decoded from the
|
||||||
|
audio signal, behind the admin **LTC listener** switch (off ⇒ no LTC tile
|
||||||
|
in the picker at all). On, the module captures the selected audio input
|
||||||
|
device/channel on the ProdDash machine — a native `ltc-capture` helper,
|
||||||
|
compiled on first use — and decodes the SMPTE frames in-process:
|
||||||
|
running/stopped/no-signal, the timecode counting frame-accurately in real
|
||||||
|
time, and the frame rate (incl. 29.97 drop-frame). macOS note: the
|
||||||
|
ProdDash server needs microphone permission — launch it from a logged-in
|
||||||
|
session (Start ProdDash.command) and approve the one-time prompt; a server
|
||||||
|
started over SSH or by bare launchd silently captures zeros. (Engine
|
||||||
|
self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
|
||||||
|
- **PCO Plan** — the order of service from Planning Center Services: every
|
||||||
|
plan item with its scheduled start time, length, type, song key,
|
||||||
|
description and item notes. When the ProPresenter module is running it
|
||||||
|
follows that module's live item, highlights the matching plan item, dims
|
||||||
|
what's done and shows the elapsed vs planned runtime and how far
|
||||||
|
ahead/behind the service is. Admin config is the connection: Planning
|
||||||
|
Center credentials (Personal Access Token app ID + secret, or an OAuth
|
||||||
|
token), the service type (a list grouped by your Planning Center folders)
|
||||||
|
and an optional pinned plan. Everything about the display is per tile
|
||||||
|
(gear menu): the top-bar text (placeholders such as
|
||||||
|
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
|
||||||
|
show/hide checklist for every detail.
|
||||||
- **Clock** — a big booth clock; also the smallest possible module and the
|
- **Clock** — a big booth clock; also the smallest possible module and the
|
||||||
reference for module authors.
|
reference for module authors.
|
||||||
|
|
||||||
To develop modules against mocks instead of real gear:
|
To develop modules against mocks instead of real gear:
|
||||||
`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`),
|
`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`),
|
||||||
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on
|
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on
|
||||||
`127.0.0.1:1599`) and `node tools/pp-timers-mock.js 1600` (ProPresenter
|
`127.0.0.1:1599`), `node tools/pp-timers-mock.js 1600` (ProPresenter
|
||||||
timers + timecode on `127.0.0.1:1600`). See the testing section of the
|
timers + timecode on `127.0.0.1:1600`) and `node tools/pco-mock.js`
|
||||||
|
(Planning Center Services on `127.0.0.1:24700` — run ProdDash with
|
||||||
|
`PCO_PLAN_API_BASE=http://127.0.0.1:24700`). See the testing section of the
|
||||||
[module guide](docs/MODULE-GUIDE.md).
|
[module guide](docs/MODULE-GUIDE.md).
|
||||||
|
|
||||||
## Install as a desktop web app
|
## Install as a desktop web app
|
||||||
@@ -130,7 +288,9 @@ ProdDash/
|
|||||||
│ ├── clock/
|
│ ├── clock/
|
||||||
│ ├── prodcom-transcript/
|
│ ├── prodcom-transcript/
|
||||||
│ └── propresenter-now-next/ (carries its own copy of propresenter-core)
|
│ └── propresenter-now-next/ (carries its own copy of propresenter-core)
|
||||||
├── config/ proddash.json + state written by the app
|
├── package.json name + version (what the update check compares)
|
||||||
|
├── config/ proddash.json defaults (runtime state lives in the
|
||||||
|
│ per-machine data directory — see "Configure")
|
||||||
├── docs/MODULE-GUIDE.md how to build and install a module
|
├── docs/MODULE-GUIDE.md how to build and install a module
|
||||||
├── tools/prodcom-mock.js mock ProdCom API for development
|
├── tools/prodcom-mock.js mock ProdCom API for development
|
||||||
├── prodcom-listener/ reference fork — read-only, not served
|
├── prodcom-listener/ reference fork — read-only, not served
|
||||||
|
|||||||
Regular → Executable
+7
@@ -1,4 +1,11 @@
|
|||||||
#!/bin/zsh
|
#!/bin/zsh
|
||||||
# Double-click to start ProdDash (macOS)
|
# Double-click to start ProdDash (macOS)
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
# One dashboard per machine: take the port over from any prior instance
|
||||||
|
# (an SSH- or launchd-started one has no microphone access for the LTC
|
||||||
|
# listener — launching from here fixes that).
|
||||||
|
OLD=$(lsof -t -iTCP:24500 -sTCP:LISTEN 2>/dev/null)
|
||||||
|
[[ -n "$OLD" ]] && kill $OLD 2>/dev/null && sleep 1
|
||||||
|
# Prefer the bundled runtime (production machines have no system Node)
|
||||||
|
if [[ -x ./runtime/bin/node ]]; then exec ./runtime/bin/node server.js; fi
|
||||||
exec node server.js
|
exec node server.js
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
"port": 24500,
|
"port": 24500,
|
||||||
"adminPasscode": ""
|
"adminPasscode": "",
|
||||||
|
"repo": "michaelmcgary2008/ProdDash",
|
||||||
|
"branch": "main"
|
||||||
}
|
}
|
||||||
|
|||||||
+284
-20
@@ -9,6 +9,31 @@ their own dashboards, move and resize them, and give each tile its own
|
|||||||
per-tile settings. Your module renders inside the tile and talks to the
|
per-tile settings. Your module renders inside the tile and talks to the
|
||||||
outside world only through the small API the shell hands it.
|
outside world only through the small API the shell hands it.
|
||||||
|
|
||||||
|
## Rules every module follows
|
||||||
|
|
||||||
|
These are not style preferences — the admin page, the installer and the
|
||||||
|
people running the booth depend on them.
|
||||||
|
|
||||||
|
1. **All settings live in the ProdDash admin page.** Server-wide settings go
|
||||||
|
in `configSchema`, per-tile settings in `instanceSchema`. Never a separate
|
||||||
|
setup page, wizard, config file the operator edits by hand, or environment
|
||||||
|
variable.
|
||||||
|
2. **Dependencies ship inside the module folder.** Zero dependencies is the
|
||||||
|
norm (the shell has none). Anything you genuinely need is vendored into
|
||||||
|
the module — or fetched by your own `init()` on first start, with the
|
||||||
|
admin health line saying what is happening. No `npm install`, no
|
||||||
|
installer, no "run this first".
|
||||||
|
3. **Everything the module does runs inside the module.** No helper scripts,
|
||||||
|
companion programs, daemons or services the operator has to start next to
|
||||||
|
ProdDash. If your feature needs a listener, a decoder or a worker, your
|
||||||
|
server entry owns it: start it in `init()`, stop it in `stop()`, report
|
||||||
|
it in `health()`. A child process the module itself spawns and supervises
|
||||||
|
is fine; a program someone launches by hand is not.
|
||||||
|
4. **Declare the ProdDash version you need** (`"proddash": ">=1.1.0"` in the
|
||||||
|
manifest) and **bump your own `version`** whenever you change the module.
|
||||||
|
The admin page refuses to install a module whose requirement the running
|
||||||
|
shell doesn't meet, and it offers updates by comparing versions.
|
||||||
|
|
||||||
## The shape of a module
|
## The shape of a module
|
||||||
|
|
||||||
A module is one folder dropped into `modules/`:
|
A module is one folder dropped into `modules/`:
|
||||||
@@ -21,9 +46,16 @@ modules/<id>/
|
|||||||
└── ... assets (css, images…) served statically at /modules/<id>/
|
└── ... assets (css, images…) served statically at /modules/<id>/
|
||||||
```
|
```
|
||||||
|
|
||||||
**Installation is exactly this:** copy the folder into `modules/`, restart
|
**Installation is exactly this:** in the admin page, open **Available
|
||||||
ProdDash (`node server.js`), then configure and enable it at `/admin`. No
|
modules** and press **Install** — the module is downloaded from the ProdDash
|
||||||
shell code changes, ever.
|
repo into the server's data directory (`<data dir>/modules/<id>/`) and starts
|
||||||
|
immediately. Developing locally? Drop the folder into `modules/` (bundled
|
||||||
|
with the checkout) or into the data directory's `modules/` and restart. No
|
||||||
|
shell code changes, ever. Modules bundled with a checkout and modules
|
||||||
|
installed from the admin page live side by side; when both have the same id
|
||||||
|
the newer version runs. **Uninstall** in the admin page removes an installed
|
||||||
|
module's files (a bundled one is hidden instead, since its folder belongs to
|
||||||
|
the app); its settings are kept for a later reinstall.
|
||||||
|
|
||||||
## module.json
|
## module.json
|
||||||
|
|
||||||
@@ -32,7 +64,11 @@ shell code changes, ever.
|
|||||||
"id": "my-module",
|
"id": "my-module",
|
||||||
"name": "My Module",
|
"name": "My Module",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"proddash": ">=1.1.0",
|
||||||
"description": "One line shown in the Add-tile picker and the admin page",
|
"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",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
@@ -52,35 +88,175 @@ shell code changes, ever.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | yes | Must equal the folder name. Lowercase, dashes. |
|
| `id` | yes | Must equal the folder name. Lowercase, dashes. |
|
||||||
| `name` | yes | Human name shown on the tile header and in menus. |
|
| `name` | yes | Human name shown on the tile header and in menus. |
|
||||||
| `version`, `description` | no | Shown in the admin page / picker. |
|
| `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. |
|
||||||
|
| `provides` | no | Capabilities other modules can consume — `["timers"]` today. Forwarded in `/api/modules` so a consumer can find you. See **Working with other modules**. |
|
||||||
| `client` | yes | Client entry file, loaded as an ES module. |
|
| `client` | yes | Client entry file, loaded as an ES module. |
|
||||||
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
||||||
| `style` | no | A stylesheet the shell injects once per module. |
|
| `style` | no | A stylesheet the shell injects once per module. |
|
||||||
| `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. |
|
| `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. |
|
||||||
| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. |
|
| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. |
|
||||||
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). |
|
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). They apply as they are changed, so keep them to display choices. |
|
||||||
| `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. |
|
| `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. |
|
||||||
|
|
||||||
Both schemas map field names to specs:
|
Both schemas map field names to specs:
|
||||||
|
|
||||||
```
|
```
|
||||||
{ "type": "string" | "number" | "boolean" | "select" | "password",
|
{ "type": "string" | "number" | "boolean" | "switch" | "select" | "color" | "password" | "endpoint",
|
||||||
"label": "Shown next to the field",
|
"label": "Shown next to the field",
|
||||||
"default": <value>,
|
"default": <value>,
|
||||||
"options": [ { "value": "a", "label": "A" }, … ] // "select" only
|
"group": "Section heading", // optional; see below
|
||||||
|
"showWhen": "<other boolean/switch key>", // optional; see below
|
||||||
|
"help": "One line shown under the control", // optional, admin config only
|
||||||
|
"options": [ { "value": "a", "label": "A", "group": "Optgroup" }, … ], // "select" only
|
||||||
|
"optionsRoute": "/devices", // "select" only, admin config only
|
||||||
|
"optionsDependsOn": "<other key>" // with optionsRoute; see below
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`boolean` and `switch` are the same true/false value. In a tile's gear popover
|
||||||
|
both render as a switch; in the admin page `switch` is the toggle and
|
||||||
|
`boolean` the plainer checkbox (use `switch` for a feature on/off that other
|
||||||
|
fields depend on). **Label a switch with the noun of what it turns on** —
|
||||||
|
"Seconds", "Top bar", "Section headers" — never "Show seconds": on means
|
||||||
|
shown or enabled, off means hidden or disabled, and the label shouldn't say
|
||||||
|
so twice.
|
||||||
|
`color` renders a swatch picker and stores a `#rrggbb` string (give it a hex
|
||||||
|
default; the tile applies it as a CSS custom property — see `pco-plan`).
|
||||||
|
|
||||||
|
`group` gathers consecutive fields under a labeled subsection in the admin
|
||||||
|
page, so a module with several distinct concerns (e.g. a ProPresenter
|
||||||
|
connection and an LTC audio input) reads as clearly separated blocks. Fields
|
||||||
|
with no `group` render loose, before/after the grouped ones in schema order.
|
||||||
|
|
||||||
|
A manifest-level `configGroups` map can dress a group up further, keyed by
|
||||||
|
the group name:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"configGroups": {
|
||||||
|
"Planning Center credentials": { "collapsed": "whenSet", "help": "Where to get a token…" },
|
||||||
|
"Show / hide": { "collapsed": true, "columns": 3 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `collapsed`: `true` (starts folded), `false` (starts open but can fold),
|
||||||
|
or `"whenSet"` — folded once the group already holds values (a saved
|
||||||
|
password, or anything other than the schema default), open while it is
|
||||||
|
still empty. Omit it and the group is a plain, always-open block.
|
||||||
|
- `columns`: 2–4 lays the group's fields out in a grid — the way to keep a
|
||||||
|
long checklist of booleans compact.
|
||||||
|
- `help`: guidance for the whole group. The admin page shows it as an
|
||||||
|
ⓘ beside the group title (click or hover to read), so a group can carry a
|
||||||
|
paragraph without pushing its fields down the page. Say where a value comes
|
||||||
|
from, not what a field is.
|
||||||
|
- `toggle`: the key of a `switch` field in this group. It renders in the
|
||||||
|
group's title row, at the right, as the group's on/off; the rest of the group
|
||||||
|
dims while it is off, and the field is not repeated in the body. This is how
|
||||||
|
an integration is disabled without unmounting the module — ProPresenter
|
||||||
|
timers, say, on a Sunday when ProPresenter isn't running.
|
||||||
|
|
||||||
|
`help` on a field puts one line of guidance under its control — allowed
|
||||||
|
placeholders, where a value comes from. Keep labels short and put the
|
||||||
|
explanation there.
|
||||||
|
|
||||||
|
`group` and `help` work on `instanceSchema` fields too, and a manifest
|
||||||
|
`instanceGroups` map plays the `configGroups` role for the tile's gear
|
||||||
|
popover (`collapsed: true | false`, `columns`, `help`). That is how a module
|
||||||
|
with a long show/hide checklist keeps the popover usable — see `pco-plan`.
|
||||||
|
Display choices belong there, per tile; admin config is for connections.
|
||||||
|
|
||||||
|
`showWhen: "<key>"` hides a field until the boolean/`switch` field named
|
||||||
|
`<key>` is on — e.g. the audio device and channel appear only once the LTC
|
||||||
|
listener switch is enabled. The hidden field still keeps its stored value.
|
||||||
|
|
||||||
|
A `select` whose choices are only knowable at runtime (audio devices, serial
|
||||||
|
ports, discovered sources) sets `optionsRoute` instead of — or as a fallback
|
||||||
|
refresh of — static `options`: the admin page GETs
|
||||||
|
`/api/modules/<id><optionsRoute>` and expects `{ "options": [ { "value",
|
||||||
|
"label" }, … ] }` from one of the module's own server routes. The saved value
|
||||||
|
stays selectable even when the route is down or the option has vanished. (See
|
||||||
|
`propresenter-timers`' "Audio device" for a worked example.)
|
||||||
|
|
||||||
|
An option may carry a `group`: options sharing one render under a single
|
||||||
|
`<optgroup>`, so a tree (Planning Center folders, device classes) is
|
||||||
|
browsable inside the select. `optionsDependsOn: "<key>"` makes the admin
|
||||||
|
page fetch the options with that other field's current value appended as a
|
||||||
|
query parameter (`?<key>=<value>`), now and again whenever it changes — e.g.
|
||||||
|
a plan list that follows the chosen service type. (Both in `pco-plan`.)
|
||||||
|
|
||||||
`password` fields are admin-config only in practice: their values are stored
|
`password` fields are admin-config only in practice: their values are stored
|
||||||
in `config/modules.json`, handed to your *server* entry, and **never sent to
|
in the server's `modules.json` (in the per-machine data directory, see the
|
||||||
browsers** — not in `/api/modules`, and never echoed back into the admin
|
README), handed to your *server* entry, and **never sent to browsers** — not in `/api/modules`, and never echoed back into the admin
|
||||||
form. Anything upstream that needs the secret must go through your server
|
form. Anything upstream that needs the secret must go through your server
|
||||||
routes.
|
routes.
|
||||||
|
|
||||||
Admin config lives in `config/modules.json` (written by the admin page —
|
### Connection settings: use `endpoint`
|
||||||
your module never touches that file). Per-tile settings live inside each
|
|
||||||
|
Anything that points at a device or service on the network — ProPresenter,
|
||||||
|
ProdCom, a camera, a mixer — must be **one `endpoint` field**, so every
|
||||||
|
module's connection is entered the same way. The admin page renders an
|
||||||
|
endpoint as a single "Host / IP : Port" control; the value is stored and
|
||||||
|
handed to your server as an object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"propresenter": {
|
||||||
|
"type": "endpoint",
|
||||||
|
"label": "ProPresenter",
|
||||||
|
"default": { "host": "", "port": 1025 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// in your server entry
|
||||||
|
const { host, port } = config.propresenter;
|
||||||
|
const base = `http://${host}:${port}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not** split host and port into separate `string`/`number` fields, and
|
||||||
|
do **not** ask for a full `http://…` URL unless the setting genuinely is a
|
||||||
|
web address where protocol or path matter (a page to embed, a webhook).
|
||||||
|
Endpoints belong in admin config, not per-tile settings — connections are
|
||||||
|
server-wide by design.
|
||||||
|
|
||||||
|
Admin config lives in `modules.json` inside the server's data directory
|
||||||
|
(written by the admin page — your module never touches that file, and it
|
||||||
|
survives updates of the app folder). Per-tile settings live inside each
|
||||||
browser's layout.
|
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`)
|
## Client entry (`client.js`)
|
||||||
|
|
||||||
An ES module whose **default export is a factory**. The shell calls it once
|
An ES module whose **default export is a factory**. The shell calls it once
|
||||||
@@ -179,10 +355,13 @@ own in `stop()` anyway.
|
|||||||
- **Any number of instances.** Two transcript tiles with different filters
|
- **Any number of instances.** Two transcript tiles with different filters
|
||||||
is a feature. Keep all state inside `create()`'s closure — no module-level
|
is a feature. Keep all state inside `create()`'s closure — no module-level
|
||||||
mutable state in `client.js`.
|
mutable state in `client.js`.
|
||||||
- **Remounts are routine.** When the user edits the tile's gear settings the
|
- **Remounts are routine.** Gear settings apply as the user changes them —
|
||||||
shell calls `stop()` and starts a fresh instance. When admin config
|
there is no Apply button — so each change calls `stop()` and starts a fresh
|
||||||
changes, the shell calls your `onConfigChange(cfg)` if you export one,
|
instance (typing is debounced, and an unchanged value remounts nothing).
|
||||||
otherwise it remounts you. Either way you must come back cleanly.
|
Starting cheaply matters: hold connections and timers in the closure and
|
||||||
|
tear them down in `stop()`. When admin config changes, the shell calls your
|
||||||
|
`onConfigChange(cfg)` if you export one, otherwise it remounts you. Either
|
||||||
|
way you must come back cleanly.
|
||||||
- **The tile can be removed at any moment.** `stop()` is your only notice.
|
- **The tile can be removed at any moment.** `stop()` is your only notice.
|
||||||
|
|
||||||
### Rules for the client
|
### Rules for the client
|
||||||
@@ -198,9 +377,15 @@ own in `stop()` anyway.
|
|||||||
|
|
||||||
2. **Style with the shell's CSS variables — no hardcoded colors.** The
|
2. **Style with the shell's CSS variables — no hardcoded colors.** The
|
||||||
palette: `--bg` (page), `--panel` (tile), `--border`, `--text`,
|
palette: `--bg` (page), `--panel` (tile), `--border`, `--text`,
|
||||||
`--muted`, `--accent`, `--danger`, `--warn`. Derive tints with
|
`--muted`, `--accent`, `--accent-text` (text that sits on the accent —
|
||||||
|
dark on most themes, white where the accent is deep), `--danger`,
|
||||||
|
`--warn`. Derive tints with
|
||||||
`color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes
|
`color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes
|
||||||
every module look native on the dark, high-contrast booth theme.
|
every module look native — and what makes the dashboard's **themes**
|
||||||
|
(Admin → Theme) reach into every tile: a theme swaps those variables and
|
||||||
|
nothing else. A colour a tile is configured with (a `color` instance
|
||||||
|
setting, applied as a custom property on the tile) is set closer than the
|
||||||
|
theme and wins over it, by design.
|
||||||
|
|
||||||
3. **No external network calls from the client.** Browsers talk only to
|
3. **No external network calls from the client.** Browsers talk only to
|
||||||
ProdDash. Anything upstream (your device, service, API) goes through your
|
ProdDash. Anything upstream (your device, service, API) goes through your
|
||||||
@@ -220,7 +405,7 @@ ethos; Node 18+ so global `fetch` is available). Exports either or both of:
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
module.exports = {
|
module.exports = {
|
||||||
init({ config, log }) {
|
init({ config, log, shell }) {
|
||||||
// Called on startup / re-init, BEFORE routes(). Start your upstream
|
// Called on startup / re-init, BEFORE routes(). Start your upstream
|
||||||
// connection here. Return a handle:
|
// connection here. Return a handle:
|
||||||
return {
|
return {
|
||||||
@@ -229,7 +414,7 @@ module.exports = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
routes({ config, log }) {
|
routes({ config, log, shell }) {
|
||||||
// Route table, mounted under /api/modules/<id>/
|
// Route table, mounted under /api/modules/<id>/
|
||||||
return {
|
return {
|
||||||
'GET /state': (req, res) => { … },
|
'GET /state': (req, res) => { … },
|
||||||
@@ -245,6 +430,9 @@ module.exports = {
|
|||||||
- `config` is your admin config: `configSchema` defaults overlaid with what
|
- `config` is your admin config: `configSchema` defaults overlaid with what
|
||||||
the admin page saved — **including password fields**.
|
the admin page saved — **including password fields**.
|
||||||
- `log(...)` prefixes output with your module id.
|
- `log(...)` prefixes output with your module id.
|
||||||
|
- `shell.port` is the port ProdDash is serving on — what you need to read
|
||||||
|
another module's routes over loopback (`http://127.0.0.1:${shell.port}/api/
|
||||||
|
modules/<id>/…`). Never guess it.
|
||||||
- Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is
|
- Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is
|
||||||
the matched remainder (leading `/` included) and `req.search` is the query
|
the matched remainder (leading `/` included) and `req.search` is the query
|
||||||
string (with `?`, or `''`) — so a proxy rebuilds the upstream path as
|
string (with `?`, or `''`) — so a proxy rebuilds the upstream path as
|
||||||
@@ -331,6 +519,71 @@ per-client `res.write` in try/catch (drop the client on failure). The
|
|||||||
`propresenter-now-next` module is the reference implementation of this
|
`propresenter-now-next` module is the reference implementation of this
|
||||||
pattern.
|
pattern.
|
||||||
|
|
||||||
|
## Working with other modules
|
||||||
|
|
||||||
|
Modules don't import each other. When one needs what another knows, it reads
|
||||||
|
that module's **own server routes over loopback**, exactly as a browser would:
|
||||||
|
`http://127.0.0.1:${shell.port}/api/modules/<id>/…`. The PCO Plan module
|
||||||
|
follows ProPresenter this way — it reads `propresenter-now-next`'s `/stream`
|
||||||
|
rather than talking to ProPresenter a second time. A consumer must survive the
|
||||||
|
provider being disabled, uninstalled, or restarting mid-service (404s and
|
||||||
|
dropped connections are normal; retry with backoff and say so in `health()`).
|
||||||
|
|
||||||
|
### Offering timers to the Timers module
|
||||||
|
|
||||||
|
The Timers module shows timers from anywhere: ProPresenter, LTC, its own
|
||||||
|
clock, and any module that offers some. A module offers timers by:
|
||||||
|
|
||||||
|
1. Declaring it — `"provides": ["timers"]` in `module.json`. The shell
|
||||||
|
forwards `provides` in `/api/modules`, which is how the Timers module finds
|
||||||
|
providers without a list anyone maintains.
|
||||||
|
2. Serving `GET /timers` from its server routes, returning a snapshot:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"source": "pco-plan",
|
||||||
|
"label": "PCO Plan",
|
||||||
|
"updatedAt": 1757800000000,
|
||||||
|
"timers": [
|
||||||
|
{
|
||||||
|
"id": "service",
|
||||||
|
"label": "Service",
|
||||||
|
"kind": "elapsed",
|
||||||
|
"state": "running",
|
||||||
|
"startedAt": 1757799000000,
|
||||||
|
"elapsedMs": 1000000,
|
||||||
|
"remainingMs": null,
|
||||||
|
"targetMs": 4500000,
|
||||||
|
"status": { "text": "Welcome & Announcements", "tone": "ok" },
|
||||||
|
"detail": "Item 3 of 12"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `source`, `label` | Your module id, and the heading the Timers module puts over your timers. |
|
||||||
|
| `updatedAt` | ms since epoch when this snapshot was taken. Every duration below is *as of this instant*. |
|
||||||
|
| `id` | Stable within your module — the Timers module keys tile settings (shown/hidden, colours) on `source` + `id`. |
|
||||||
|
| `kind` | `elapsed` (counts up), `countdown` (counts down to zero), `clock` (a time of day; put the ISO time in `status.text`). |
|
||||||
|
| `state` | `running`, `paused`, `stopped` (finished; value frozen), `idle` (exists, hasn't started — a timer that will start at 11:00). |
|
||||||
|
| `startedAt` | ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll. |
|
||||||
|
| `elapsedMs`, `remainingMs` | The values at `updatedAt`; `null` when not meaningful for the kind. |
|
||||||
|
| `targetMs` | Planned length (elapsed) or full length (countdown), or `null`. Lets the consumer colour overruns. |
|
||||||
|
| `status` | Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". `tone`: `ok`, `warn`, `danger`, `muted`. |
|
||||||
|
| `detail` | Optional second line. |
|
||||||
|
|
||||||
|
The consumer derives the displayed time locally from `startedAt`/`elapsedMs`
|
||||||
|
and its own clock, so a snapshot every couple of seconds is plenty — `/timers`
|
||||||
|
must be cheap to answer (serve from state you already hold; never call
|
||||||
|
upstream to answer it). Optionally also serve `GET /timers/stream` as SSE
|
||||||
|
(`event: timers`, `data:` the same object, on every change plus a heartbeat
|
||||||
|
comment every 15 s); the consumer uses the stream when it exists and polls
|
||||||
|
`/timers` otherwise. Only offer timers a production tech would want on a
|
||||||
|
wall — the Timers module lets them hide any of them per tile, but every entry
|
||||||
|
you add is a decision someone has to make.
|
||||||
|
|
||||||
## Complete minimal example
|
## Complete minimal example
|
||||||
|
|
||||||
A working clock — copy-paste this as your starter. (A slightly fancier
|
A working clock — copy-paste this as your starter. (A slightly fancier
|
||||||
@@ -432,7 +685,18 @@ client-side failures (a tile that fails to start says so in the tile).
|
|||||||
- ProPresenter timers: `node tools/pp-timers-mock.js 1600` — running,
|
- ProPresenter timers: `node tools/pp-timers-mock.js 1600` — running,
|
||||||
overrunning, stopped and elapsed timers plus an LTC timecode that
|
overrunning, stopped and elapsed timers plus an LTC timecode that
|
||||||
periodically drops to no-signal (`--no-ltc` serves a ProPresenter without
|
periodically drops to no-signal (`--no-ltc` serves a ProPresenter without
|
||||||
a timecode API). Point the Timers module at host `127.0.0.1`, port `1600`.
|
a timecode API). Also serves the stage-display websocket with an
|
||||||
|
LTC-labeled layout field (`--stage-pwd`, `--no-ltc-field` exercise its
|
||||||
|
failure paths). Point the Timers module at host `127.0.0.1`, port `1600`.
|
||||||
|
- Planning Center: `node tools/pco-mock.js` (port 24700) — folders, service
|
||||||
|
types, plans (last week / today / next week), plan times and items with
|
||||||
|
songs, keys and item notes, behind a Basic/Bearer auth check. Planning
|
||||||
|
Center is a cloud API, so the module reads its base URL from the
|
||||||
|
environment instead of admin config: run ProdDash with
|
||||||
|
`PCO_PLAN_API_BASE=http://127.0.0.1:24700 node server.js`, enter any
|
||||||
|
Application ID + Secret under PCO Plan in `/admin`, save, then pick
|
||||||
|
"Sunday Service" from the Service type list. Its item titles match
|
||||||
|
`pp-mock.js`, so running both mocks exercises the ProPresenter matching.
|
||||||
|
|
||||||
**The resilience checklist** — every module must pass this before it's done:
|
**The resilience checklist** — every module must pass this before it's done:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"id": "clock",
|
"id": "clock",
|
||||||
"name": "Clock",
|
"name": "Clock",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"description": "Big booth clock — the smallest possible ProdDash module",
|
"proddash": ">=1.1.0",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
"minSize": { "w": 2, "h": 1 },
|
"minSize": { "w": 2, "h": 1 },
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
{ "value": "12h", "label": "12-hour" }
|
{ "value": "12h", "label": "12-hour" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"showSeconds": { "type": "boolean", "label": "Show seconds", "default": true },
|
"showSeconds": { "type": "boolean", "label": "Seconds", "default": true },
|
||||||
"showDate": { "type": "boolean", "label": "Show date", "default": false }
|
"showDate": { "type": "boolean", "label": "Date", "default": false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,644 @@
|
|||||||
|
/* PCO Plan — client part.
|
||||||
|
|
||||||
|
The server part reads Planning Center and (optionally) follows the
|
||||||
|
ProPresenter module's live item; this tile only renders the plan it is
|
||||||
|
streamed. What is shown is this tile's own choice (gear menu →
|
||||||
|
moduleApi.instanceSettings: show/hide checklist, top-bar template,
|
||||||
|
colors, text size); /admin only holds the connection settings. */
|
||||||
|
|
||||||
|
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
||||||
|
|
||||||
|
export default function create({ root, moduleApi }) {
|
||||||
|
let state = null;
|
||||||
|
let skew = 0; // server clock − local clock, ms
|
||||||
|
let feedOffline = false;
|
||||||
|
let stream = null;
|
||||||
|
let ticker = null;
|
||||||
|
let lastCurrentId = null;
|
||||||
|
let lastListKey = '';
|
||||||
|
let userScrolledAt = 0;
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="pp-wrap">
|
||||||
|
<header class="pp-head">
|
||||||
|
<div class="pp-plan">
|
||||||
|
<div class="pp-plan-title"></div>
|
||||||
|
<div class="pp-plan-sub"></div>
|
||||||
|
</div>
|
||||||
|
<div class="pp-clock">
|
||||||
|
<div class="pp-clock-label"></div>
|
||||||
|
<div class="pp-clock-value"></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="pp-list"></div>
|
||||||
|
<footer class="pp-foot" hidden></footer>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const wrap = root.querySelector('.pp-wrap');
|
||||||
|
const headEl = root.querySelector('.pp-head');
|
||||||
|
const planTitleEl = root.querySelector('.pp-plan-title');
|
||||||
|
const planSubEl = root.querySelector('.pp-plan-sub');
|
||||||
|
const clockLabelEl = root.querySelector('.pp-clock-label');
|
||||||
|
const clockValueEl = root.querySelector('.pp-clock-value');
|
||||||
|
const listEl = root.querySelector('.pp-list');
|
||||||
|
const footEl = root.querySelector('.pp-foot');
|
||||||
|
|
||||||
|
/** This tile's display settings (gear menu) — read fresh on every render. */
|
||||||
|
const cfg = () => moduleApi.instanceSettings || {};
|
||||||
|
const on = (key) => cfg()[key] !== false; // unset = shown
|
||||||
|
const tilePrefs = cfg;
|
||||||
|
const serverNow = () => Date.now() + skew;
|
||||||
|
|
||||||
|
/* ── title-bar controls ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const refreshBtn = moduleApi.header.addButton({
|
||||||
|
icon: REFRESH_SVG,
|
||||||
|
title: 'Re-read the plan from Planning Center now',
|
||||||
|
onClick() {
|
||||||
|
refreshBtn.classList.add('active');
|
||||||
|
moduleApi.fetch('/refresh', { method: 'POST' })
|
||||||
|
.catch(() => { /* the stream reports the outcome */ })
|
||||||
|
.finally(() => refreshBtn.classList.remove('active'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function bumpTextSize(delta) {
|
||||||
|
const size = Number(tilePrefs().textSize) || 14;
|
||||||
|
moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
|
||||||
|
applyPrefs();
|
||||||
|
}
|
||||||
|
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-1) });
|
||||||
|
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(1) });
|
||||||
|
|
||||||
|
/* ── formatting ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function fmtClock(ms) {
|
||||||
|
if (!ms) return '';
|
||||||
|
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDur(totalSec, { signed = false } = {}) {
|
||||||
|
const sign = totalSec < 0 ? '-' : (signed ? '+' : '');
|
||||||
|
const s = Math.abs(Math.round(totalSec));
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
if (h) return `${sign}${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||||
|
return `${sign}${m}:${String(sec).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtMinutes(totalSec) {
|
||||||
|
const m = Math.round(totalSec / 60);
|
||||||
|
if (m < 60) return `${m} min`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
const rest = m % 60;
|
||||||
|
return rest ? `${h} h ${rest} min` : `${h} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(itemType) {
|
||||||
|
switch (itemType) {
|
||||||
|
case 'song': return 'Song';
|
||||||
|
case 'media': return 'Media';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function plannedLength() {
|
||||||
|
return (state?.items || [])
|
||||||
|
.filter((it) => it.servicePosition === 'during')
|
||||||
|
.reduce((sum, it) => sum + (it.length || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── which items to show ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function visibleItems() {
|
||||||
|
const items = Array.isArray(state?.items) ? state.items : [];
|
||||||
|
return items.filter((it) => {
|
||||||
|
if (it.itemType === 'header' && !on('showHeaders')) return false;
|
||||||
|
if (it.servicePosition === 'pre' && !on('showPreService')) return false;
|
||||||
|
if (it.servicePosition === 'post' && !on('showPostService')) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteFilter() {
|
||||||
|
const raw = String(cfg().noteCategories || '');
|
||||||
|
const wanted = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||||
|
return wanted.length ? (note) => wanted.includes(String(note.category || '').toLowerCase()) : () => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── status dot ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function renderStatus() {
|
||||||
|
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
|
||||||
|
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
|
||||||
|
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
|
||||||
|
if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin');
|
||||||
|
if (!state.reachable) {
|
||||||
|
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
|
||||||
|
}
|
||||||
|
const live = state.live || {};
|
||||||
|
let extra = '';
|
||||||
|
if (live.following) {
|
||||||
|
extra = live.available
|
||||||
|
? (live.ppReachable ? ' · following ProPresenter' : ' · ProPresenter unreachable')
|
||||||
|
: ` · ${live.reason || 'ProPresenter feed unavailable'}`;
|
||||||
|
}
|
||||||
|
moduleApi.setStatus('ok', `Plan read ${new Date(state.lastFetched).toLocaleTimeString()}${extra}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── top bar: admin template with placeholders ──────────────────── */
|
||||||
|
|
||||||
|
const DEFAULT_TEMPLATE = '{series} • {part} | {serviceType} • {date}';
|
||||||
|
|
||||||
|
/** Placeholder → text; a hidden or empty value resolves to '' and drops its segment. */
|
||||||
|
function templateValues() {
|
||||||
|
const plan = state?.plan || {};
|
||||||
|
const items = state?.items || [];
|
||||||
|
const planned = plannedLength();
|
||||||
|
return {
|
||||||
|
series: on('showSeries') ? plan.seriesTitle || '' : '',
|
||||||
|
part: on('showPart') ? plan.title || '' : '',
|
||||||
|
title: on('showPart') ? plan.title || '' : '',
|
||||||
|
servicetype: on('showServiceType') ? state?.serviceType?.name || '' : '',
|
||||||
|
date: on('showDate') ? plan.dates || '' : '',
|
||||||
|
shortdate: on('showDate') ? plan.shortDates || plan.dates || '' : '',
|
||||||
|
time: on('showServiceTime') && plan.serviceStartsAt
|
||||||
|
? `${plan.serviceName && plan.serviceName !== 'Service' ? `${plan.serviceName} ` : ''}${fmtClock(plan.serviceStartsAt)}`
|
||||||
|
: '',
|
||||||
|
length: on('showTotalLength') && planned ? fmtMinutes(planned) : '',
|
||||||
|
items: items.filter((it) => it.itemType !== 'header').length ? String(items.filter((it) => it.itemType !== 'header').length) : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "{series} • {part} | {serviceType} • {date}" → up to two lines. Within a
|
||||||
|
* line, • (or ·) separates segments; a segment whose placeholders all came
|
||||||
|
* out empty is dropped, so a plan with no series just reads "Wk 2".
|
||||||
|
*/
|
||||||
|
function renderTemplate(template, values) {
|
||||||
|
const lines = String(template || DEFAULT_TEMPLATE).split('|').slice(0, 2);
|
||||||
|
return lines.map((line) => {
|
||||||
|
const segments = line.split(/\s*[•·]\s*/);
|
||||||
|
const kept = [];
|
||||||
|
for (const seg of segments) {
|
||||||
|
let sawVar = false;
|
||||||
|
let allEmpty = true;
|
||||||
|
const text = seg.replace(/\{([a-zA-Z]+)\}/g, (m, name) => {
|
||||||
|
sawVar = true;
|
||||||
|
const v = values[name.toLowerCase()];
|
||||||
|
if (v) allEmpty = false;
|
||||||
|
return v || '';
|
||||||
|
}).trim();
|
||||||
|
if (sawVar && allEmpty) continue;
|
||||||
|
if (text) kept.push(text);
|
||||||
|
}
|
||||||
|
return kept.join(' • ');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHead() {
|
||||||
|
const plan = state?.plan;
|
||||||
|
const show = on('showTopBar') && plan;
|
||||||
|
headEl.hidden = !show;
|
||||||
|
if (!show) return;
|
||||||
|
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
|
||||||
|
planTitleEl.textContent = line1 || line2 || state.serviceType?.name || 'Plan';
|
||||||
|
planSubEl.textContent = line1 ? line2 || '' : '';
|
||||||
|
planSubEl.hidden = !planSubEl.textContent;
|
||||||
|
renderClock();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The header clock follows the service the server says owns the clock
|
||||||
|
* (state.service: idle → running → stopped, by schedule or by the admin's
|
||||||
|
* ProPresenter document cues): a countdown before it, the running time
|
||||||
|
* while it runs (with ahead/behind when following ProPresenter), the final
|
||||||
|
* time once it has ended.
|
||||||
|
*/
|
||||||
|
function renderClock() {
|
||||||
|
const plan = state?.plan;
|
||||||
|
const live = state?.live || {};
|
||||||
|
const svc = state?.service || {};
|
||||||
|
clockLabelEl.textContent = '';
|
||||||
|
clockValueEl.textContent = '';
|
||||||
|
clockValueEl.className = 'pp-clock-value';
|
||||||
|
if (!plan || headEl.hidden) return;
|
||||||
|
const now = serverNow();
|
||||||
|
const startedAt = svc.startedAt || live.serviceStartedAt || 0;
|
||||||
|
if (on('showRunningClock') && svc.phase === 'running' && startedAt) {
|
||||||
|
clockLabelEl.textContent = 'Running';
|
||||||
|
clockValueEl.textContent = fmtDur((now - startedAt) / 1000);
|
||||||
|
if (live.following) {
|
||||||
|
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take.
|
||||||
|
const done = state.items.filter((it) => live.history?.[it.id]?.endedAt);
|
||||||
|
const planned = done.reduce((sum, it) => sum + (it.length || 0), 0);
|
||||||
|
const actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0);
|
||||||
|
const drift = actual - planned;
|
||||||
|
if (planned && Math.abs(drift) >= 30) {
|
||||||
|
clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`;
|
||||||
|
clockValueEl.classList.add(drift > 0 ? 'is-behind' : 'is-ahead');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (on('showRunningClock') && svc.phase === 'stopped' && startedAt && svc.endedAt) {
|
||||||
|
clockLabelEl.textContent = 'Ended';
|
||||||
|
clockValueEl.textContent = fmtDur((svc.endedAt - startedAt) / 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const startsAt = svc.startsAt || plan.serviceStartsAt || 0;
|
||||||
|
if (on('showCountdown') && startsAt > now) {
|
||||||
|
clockLabelEl.textContent = 'Starts in';
|
||||||
|
clockValueEl.textContent = fmtDur((startsAt - now) / 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (on('showRunningClock') && startsAt) {
|
||||||
|
// Past the scheduled start and not started: waiting for the admin's
|
||||||
|
// start document to go live in ProPresenter (or its failsafe).
|
||||||
|
clockLabelEl.textContent = 'Scheduled';
|
||||||
|
clockValueEl.textContent = fmtClock(startsAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the list ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function emptyMessage() {
|
||||||
|
if (feedOffline && !state) return { title: 'Feed offline', hint: 'Not receiving updates from the ProdDash server.' };
|
||||||
|
if (!state) return { title: 'Connecting…', hint: '' };
|
||||||
|
if (!state.configured) {
|
||||||
|
return {
|
||||||
|
title: 'Planning Center not connected',
|
||||||
|
hint: 'Enter the Planning Center credentials under PCO Plan in Admin.',
|
||||||
|
link: { href: '/admin', text: 'Open Admin' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!state.serviceTypeSet) {
|
||||||
|
return {
|
||||||
|
title: 'No service type selected',
|
||||||
|
hint: 'Pick the service type to follow from the list under PCO Plan in Admin.',
|
||||||
|
link: { href: '/admin', text: 'Open Admin' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!state.reachable && !state.plan) {
|
||||||
|
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
|
||||||
|
}
|
||||||
|
if (!state.plan) {
|
||||||
|
return {
|
||||||
|
title: 'No plan found',
|
||||||
|
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans in Planning Center.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by this tile’s settings (gear menu).' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRow(item, number, notesWanted) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'pp-row';
|
||||||
|
row.dataset.id = item.id;
|
||||||
|
if (item.itemType === 'header') row.classList.add('is-header');
|
||||||
|
else row.classList.add(`is-${item.itemType === 'song' || item.itemType === 'media' ? item.itemType : 'item'}`);
|
||||||
|
if (item.servicePosition !== 'during') row.classList.add(`is-${item.servicePosition}`);
|
||||||
|
const isHeader = item.itemType === 'header';
|
||||||
|
|
||||||
|
if (on('showItemNumbers')) {
|
||||||
|
const numCol = document.createElement('div');
|
||||||
|
numCol.className = 'pp-col-num';
|
||||||
|
numCol.textContent = isHeader ? '' : String(number);
|
||||||
|
row.appendChild(numCol);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (on('showStartTimes')) {
|
||||||
|
const timeCol = document.createElement('div');
|
||||||
|
timeCol.className = 'pp-col-time';
|
||||||
|
timeCol.textContent = item.startsAt && !isHeader ? fmtClock(item.startsAt) : '';
|
||||||
|
row.appendChild(timeCol);
|
||||||
|
}
|
||||||
|
|
||||||
|
const main = document.createElement('div');
|
||||||
|
main.className = 'pp-col-main';
|
||||||
|
const titleLine = document.createElement('div');
|
||||||
|
titleLine.className = 'pp-title-line';
|
||||||
|
const titleEl = document.createElement('span');
|
||||||
|
titleEl.className = 'pp-title';
|
||||||
|
titleEl.textContent = item.title || item.song?.title || '(untitled)';
|
||||||
|
titleLine.appendChild(titleEl);
|
||||||
|
if (!isHeader) {
|
||||||
|
// Order after the title: key, NOW, type badge, arrangement.
|
||||||
|
if (on('showKey') && item.keyName) {
|
||||||
|
const key = document.createElement('span');
|
||||||
|
key.className = 'pp-key';
|
||||||
|
key.textContent = item.keyName;
|
||||||
|
titleLine.appendChild(key);
|
||||||
|
}
|
||||||
|
if (on('showNowIndicator')) {
|
||||||
|
const nowTag = document.createElement('span');
|
||||||
|
nowTag.className = 'pp-now-tag';
|
||||||
|
nowTag.textContent = 'Now';
|
||||||
|
titleLine.appendChild(nowTag);
|
||||||
|
}
|
||||||
|
if (on('showItemType') && typeLabel(item.itemType)) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = `pp-badge is-${item.itemType}`;
|
||||||
|
badge.textContent = typeLabel(item.itemType);
|
||||||
|
titleLine.appendChild(badge);
|
||||||
|
}
|
||||||
|
if (on('showArrangement') && item.arrangement) {
|
||||||
|
const arr = document.createElement('span');
|
||||||
|
arr.className = 'pp-arr';
|
||||||
|
arr.textContent = item.arrangement;
|
||||||
|
titleLine.appendChild(arr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main.appendChild(titleLine);
|
||||||
|
|
||||||
|
if (!isHeader && on('showSongAuthor') && item.song && (item.song.author || item.song.ccli)) {
|
||||||
|
const author = document.createElement('div');
|
||||||
|
author.className = 'pp-author';
|
||||||
|
author.textContent = [item.song.author, item.song.ccli ? `CCLI ${item.song.ccli}` : ''].filter(Boolean).join(' · ');
|
||||||
|
main.appendChild(author);
|
||||||
|
}
|
||||||
|
if (on('showDescription') && item.description) {
|
||||||
|
const desc = document.createElement('div');
|
||||||
|
desc.className = 'pp-desc';
|
||||||
|
desc.textContent = item.description;
|
||||||
|
main.appendChild(desc);
|
||||||
|
}
|
||||||
|
if (on('showNotes') && item.notes?.length) {
|
||||||
|
const notes = item.notes.filter(notesWanted);
|
||||||
|
if (notes.length) {
|
||||||
|
const notesEl = document.createElement('div');
|
||||||
|
notesEl.className = 'pp-notes';
|
||||||
|
for (const note of notes) {
|
||||||
|
const n = document.createElement('div');
|
||||||
|
n.className = 'pp-note';
|
||||||
|
if (note.category) {
|
||||||
|
const cat = document.createElement('span');
|
||||||
|
cat.className = 'pp-note-cat';
|
||||||
|
cat.textContent = note.category;
|
||||||
|
n.appendChild(cat);
|
||||||
|
}
|
||||||
|
n.appendChild(document.createTextNode(note.content));
|
||||||
|
notesEl.appendChild(n);
|
||||||
|
}
|
||||||
|
main.appendChild(notesEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.appendChild(main);
|
||||||
|
|
||||||
|
const wantRuntime = on('showItemRuntime') && state?.live?.following;
|
||||||
|
if (on('showLength') || wantRuntime) {
|
||||||
|
const lenCol = document.createElement('div');
|
||||||
|
lenCol.className = 'pp-col-len';
|
||||||
|
if (!isHeader) {
|
||||||
|
if (on('showLength')) {
|
||||||
|
const len = document.createElement('div');
|
||||||
|
len.className = 'pp-len';
|
||||||
|
len.textContent = item.length ? fmtDur(item.length) : '';
|
||||||
|
lenCol.appendChild(len);
|
||||||
|
}
|
||||||
|
if (wantRuntime) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'pp-elapsed';
|
||||||
|
lenCol.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.appendChild(lenCol);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
const empty = emptyMessage();
|
||||||
|
if (empty) {
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
lastListKey = '';
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = 'pp-empty';
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = 'pp-empty-title';
|
||||||
|
t.textContent = empty.title;
|
||||||
|
box.appendChild(t);
|
||||||
|
if (empty.hint) {
|
||||||
|
const h = document.createElement('div');
|
||||||
|
h.className = 'pp-empty-hint';
|
||||||
|
h.textContent = empty.hint;
|
||||||
|
box.appendChild(h);
|
||||||
|
}
|
||||||
|
if (empty.link) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.className = 'pp-empty-link';
|
||||||
|
a.href = empty.link.href;
|
||||||
|
a.target = '_blank';
|
||||||
|
a.rel = 'noopener';
|
||||||
|
a.textContent = empty.link.text;
|
||||||
|
box.appendChild(a);
|
||||||
|
}
|
||||||
|
listEl.appendChild(box);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild rows only when the plan content or the display settings
|
||||||
|
// changed; live highlighting and timers are applied in place.
|
||||||
|
const key = JSON.stringify([state.plan.id, state.items, cfg(), state.live?.following]);
|
||||||
|
if (key !== lastListKey) {
|
||||||
|
lastListKey = key;
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
const notesWanted = noteFilter();
|
||||||
|
let number = 0;
|
||||||
|
for (const item of visibleItems()) {
|
||||||
|
if (item.itemType !== 'header') number += 1;
|
||||||
|
listEl.appendChild(buildRow(item, number, notesWanted));
|
||||||
|
}
|
||||||
|
lastCurrentId = null; // force the highlight + scroll pass
|
||||||
|
}
|
||||||
|
renderLiveMarks();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current / done classes and the elapsed timers, without rebuilding rows. */
|
||||||
|
function renderLiveMarks() {
|
||||||
|
const live = state?.live || {};
|
||||||
|
const now = serverNow();
|
||||||
|
const currentId = live.following ? live.currentItemId || '' : '';
|
||||||
|
const history = live.history || {};
|
||||||
|
const byId = new Map(state.items.map((it) => [it.id, it]));
|
||||||
|
// Everything before the current item counts as done, matched or not.
|
||||||
|
const doneIds = new Set();
|
||||||
|
if (currentId) {
|
||||||
|
for (const it of state.items) {
|
||||||
|
if (it.id === currentId) break;
|
||||||
|
if (it.itemType !== 'header') doneIds.add(it.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const showNow = on('showNowIndicator');
|
||||||
|
for (const row of listEl.querySelectorAll('.pp-row')) {
|
||||||
|
const id = row.dataset.id;
|
||||||
|
const item = byId.get(id);
|
||||||
|
row.classList.toggle('is-current', showNow && id === currentId);
|
||||||
|
row.classList.toggle('is-done', on('dimCompleted') && doneIds.has(id));
|
||||||
|
const elapsedEl = row.querySelector('.pp-elapsed');
|
||||||
|
if (!elapsedEl || !item) continue;
|
||||||
|
const h = history[id];
|
||||||
|
elapsedEl.className = 'pp-elapsed';
|
||||||
|
if (!h) {
|
||||||
|
elapsedEl.textContent = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const elapsedSec = ((h.endedAt || now) - h.startedAt) / 1000;
|
||||||
|
elapsedEl.textContent = fmtDur(elapsedSec);
|
||||||
|
if (item.length) {
|
||||||
|
const over = elapsedSec - item.length;
|
||||||
|
if (over > item.length * 0.25 + 30) elapsedEl.classList.add('is-over-hard');
|
||||||
|
else if (over > 0) elapsedEl.classList.add('is-over');
|
||||||
|
}
|
||||||
|
if (!h.endedAt) elapsedEl.classList.add('is-running');
|
||||||
|
}
|
||||||
|
if (currentId !== lastCurrentId) {
|
||||||
|
lastCurrentId = currentId;
|
||||||
|
if (currentId && tilePrefs().autoScroll !== false && Date.now() - userScrolledAt > 8000) {
|
||||||
|
const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`);
|
||||||
|
row?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── footer: what ProPresenter is doing ─────────────────────────── */
|
||||||
|
|
||||||
|
function renderFoot() {
|
||||||
|
const live = state?.live;
|
||||||
|
const show = on('showLiveFooter') && live?.following && state?.plan;
|
||||||
|
footEl.hidden = !show;
|
||||||
|
footEl.className = 'pp-foot';
|
||||||
|
if (!show) return;
|
||||||
|
if (!live.available) {
|
||||||
|
footEl.classList.add('is-muted');
|
||||||
|
footEl.textContent = live.reason || 'ProPresenter feed unavailable';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!live.ppEnabled) {
|
||||||
|
footEl.classList.add('is-muted');
|
||||||
|
footEl.textContent = 'ProPresenter module has no host configured';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!live.ppReachable) {
|
||||||
|
footEl.classList.add('is-down');
|
||||||
|
footEl.textContent = 'ProPresenter unreachable';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!live.activeName) {
|
||||||
|
footEl.classList.add('is-muted');
|
||||||
|
footEl.textContent = 'Nothing live in ProPresenter';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (live.matched) {
|
||||||
|
footEl.classList.add('is-live');
|
||||||
|
footEl.textContent = `Live: ${live.activeName}`;
|
||||||
|
} else {
|
||||||
|
footEl.classList.add('is-unmatched');
|
||||||
|
footEl.textContent = `Live: ${live.activeName} — not in the plan`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── render + ticking ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
wrap.classList.toggle('is-offline', feedOffline);
|
||||||
|
wrap.classList.toggle('tint-types', Boolean(cfg().tintByType));
|
||||||
|
wrap.classList.toggle('header-gradient', cfg().headerGradient !== false);
|
||||||
|
const color = (value, fallback) => {
|
||||||
|
const s = String(value || '').trim();
|
||||||
|
return /^[#a-zA-Z0-9(),.%\s-]+$/.test(s) && s ? s : fallback;
|
||||||
|
};
|
||||||
|
wrap.style.setProperty('--pp-key', color(cfg().keyColor, '#4ea1ff'));
|
||||||
|
wrap.style.setProperty('--pp-header', color(cfg().headerColor, '#2ee59a'));
|
||||||
|
wrap.style.setProperty('--pp-current', color(cfg().currentColor, '#2ee59a'));
|
||||||
|
renderStatus();
|
||||||
|
if (!state) {
|
||||||
|
headEl.hidden = true;
|
||||||
|
renderList();
|
||||||
|
footEl.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderHead();
|
||||||
|
renderList();
|
||||||
|
renderFoot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPrefs() {
|
||||||
|
const size = Math.min(40, Math.max(9, Number(tilePrefs().textSize) || 14));
|
||||||
|
wrap.style.setProperty('--pp-size', `${size}px`);
|
||||||
|
lastListKey = '';
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
if (!state?.plan) return;
|
||||||
|
renderClock();
|
||||||
|
if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── live feed ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function accept(payload) {
|
||||||
|
if (!payload || typeof payload !== 'object' || !payload.state) return;
|
||||||
|
state = payload.state;
|
||||||
|
if (Number.isFinite(payload.now)) skew = payload.now - Date.now();
|
||||||
|
feedOffline = false;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
stream = moduleApi.sse('/stream', {
|
||||||
|
open() {
|
||||||
|
feedOffline = false;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
error() {
|
||||||
|
feedOffline = true;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
state(ev) {
|
||||||
|
try {
|
||||||
|
accept(JSON.parse(ev.data));
|
||||||
|
} catch { /* a bad frame is not fatal */ }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUserScroll() {
|
||||||
|
userScrolledAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── lifecycle ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
applyPrefs();
|
||||||
|
moduleApi.setStatus('connecting', 'Connecting…');
|
||||||
|
moduleApi.fetch('/state')
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((body) => { if (body && !state) accept(body); })
|
||||||
|
.catch(() => { /* the stream will cover it */ });
|
||||||
|
connect();
|
||||||
|
listEl.addEventListener('wheel', onUserScroll, { passive: true });
|
||||||
|
listEl.addEventListener('touchmove', onUserScroll, { passive: true });
|
||||||
|
ticker = setInterval(tick, 1000);
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
clearInterval(ticker);
|
||||||
|
ticker = null;
|
||||||
|
stream?.close();
|
||||||
|
stream = null;
|
||||||
|
listEl.removeEventListener('wheel', onUserScroll);
|
||||||
|
listEl.removeEventListener('touchmove', onUserScroll);
|
||||||
|
root.innerHTML = '';
|
||||||
|
},
|
||||||
|
onConfigChange() {
|
||||||
|
// Admin holds only connection settings; the server re-inits and its
|
||||||
|
// stream re-sends the state. Nothing cached here to refresh.
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title matching for the PCO Plan module — the one answer to "is this
|
||||||
|
* ProPresenter document that Planning Center thing?", used twice:
|
||||||
|
*
|
||||||
|
* - mapping the document ProPresenter has live to a plan item (the
|
||||||
|
* highlight and the item runtimes), and
|
||||||
|
* - recognising the documents an admin picked as the service start / end
|
||||||
|
* cues (see service-timing.js).
|
||||||
|
*
|
||||||
|
* A ProPresenter name and a Planning Center title rarely agree letter for
|
||||||
|
* letter ("This Is The Day - [ Full ]" vs "This Is The Day"), so both sides
|
||||||
|
* are tidied the way propresenter-core.cleanItemName does (tags in brackets,
|
||||||
|
* leading date codes and dash separators dropped), lower-cased, stripped of
|
||||||
|
* punctuation and compared:
|
||||||
|
*
|
||||||
|
* 1.00 identical after tidying "Post-Service Loop" ≈ "Post Service Loop"
|
||||||
|
* 0.85 one contains the other "Welcome" ≈ "Welcome & Announcements"
|
||||||
|
* Dice 2·shared words ÷ all words "Pre Service Loop" vs "Post Service Loop" → 0.67
|
||||||
|
*
|
||||||
|
* Filler words ("the", "of", "and"…) are ignored. The thresholds:
|
||||||
|
*
|
||||||
|
* MATCH_THRESHOLD 0.6 plan items — two of three words is enough, because
|
||||||
|
* the item that scores best *and* is next in the
|
||||||
|
* plan wins, so a near miss lands on the right row.
|
||||||
|
* TRIGGER_THRESHOLD 0.75 service cues — a cue starts or freezes the service
|
||||||
|
* clock, so one wrong word must not fire it: the
|
||||||
|
* pre-service loop (0.67 against "Post Service Loop")
|
||||||
|
* stays a non-match, while a renamed
|
||||||
|
* "Post-Service Loop", a tagged "Post Service Loop
|
||||||
|
* [Full]" (1.0) or a shortened "Post Service" (0.85)
|
||||||
|
* still trigger.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MATCH_THRESHOLD = 0.6;
|
||||||
|
const TRIGGER_THRESHOLD = 0.75;
|
||||||
|
|
||||||
|
/** Same title tidying as propresenter-core.cleanItemName (drops [tags], date codes). */
|
||||||
|
function cleanTitle(raw) {
|
||||||
|
const original = String(raw || '').trim();
|
||||||
|
let s = original;
|
||||||
|
s = s.replace(/[[({][^\])}]*[\])}]/g, ' ');
|
||||||
|
s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, '');
|
||||||
|
s = s.replace(/\s[-–—]+\s/g, ' ');
|
||||||
|
s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, '');
|
||||||
|
s = s.replace(/\s{2,}/g, ' ').trim();
|
||||||
|
return s || original;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(raw) {
|
||||||
|
return cleanTitle(raw)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/&/g, ' and ')
|
||||||
|
.replace(/[’'`]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const STOPWORDS = new Set(['the', 'a', 'an', 'of', 'and', 'to', 'in', 'is', 'my', 'your', 'our']);
|
||||||
|
function tokens(norm) {
|
||||||
|
return new Set(norm.split(' ').filter((t) => t && !STOPWORDS.has(t)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0..1 similarity between two titles. */
|
||||||
|
function similarity(a, b) {
|
||||||
|
const na = normalize(a);
|
||||||
|
const nb = normalize(b);
|
||||||
|
if (!na || !nb) return 0;
|
||||||
|
if (na === nb) return 1;
|
||||||
|
if (na.length >= 4 && nb.length >= 4 && (na.includes(nb) || nb.includes(na))) return 0.85;
|
||||||
|
const ta = tokens(na);
|
||||||
|
const tb = tokens(nb);
|
||||||
|
if (!ta.size || !tb.size) return 0;
|
||||||
|
let common = 0;
|
||||||
|
for (const t of ta) if (tb.has(t)) common += 1;
|
||||||
|
return (2 * common) / (ta.size + tb.size); // Dice coefficient
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does a live ProPresenter document count as the cue document an admin picked? */
|
||||||
|
function isCue(liveName, cueDocument) {
|
||||||
|
return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { MATCH_THRESHOLD, TRIGGER_THRESHOLD, cleanTitle, normalize, similarity, isCue };
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
{
|
||||||
|
"id": "pco-plan",
|
||||||
|
"name": "PCO Plan",
|
||||||
|
"version": "1.4.0",
|
||||||
|
"proddash": ">=1.4.0",
|
||||||
|
"description": "Order of service from Planning Center Services",
|
||||||
|
"provides": ["timers"],
|
||||||
|
"client": "client.js",
|
||||||
|
"server": "server.js",
|
||||||
|
"style": "style.css",
|
||||||
|
"minSize": { "w": 3, "h": 3 },
|
||||||
|
"defaultSize": { "w": 5, "h": 6 },
|
||||||
|
"configGroups": {
|
||||||
|
"Planning Center credentials": {
|
||||||
|
"collapsed": "whenSet",
|
||||||
|
"help": "Create a Personal Access Token at api.planningcenteronline.com and paste its Client ID + Secret here, or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers."
|
||||||
|
},
|
||||||
|
"Service timing": {
|
||||||
|
"help": "Each service in the plan takes its turn owning the service clock: the next one takes over Pre-roll minutes before its scheduled start, and the elapsed clock, item timing and countdown start afresh for it. A service starts at its scheduled time — or, with document triggers on, the moment the chosen Start document goes live in ProPresenter (falling back to the schedule after the start failsafe) — and ends when the End document goes live, or when the next service takes over. The document lists hold what ProPresenter has shown or listed since ProdDash started plus what is saved here; names are matched loosely, so a renamed \"Post-Service Loop\" still counts."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configSchema": {
|
||||||
|
"appId": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Client ID",
|
||||||
|
"default": "",
|
||||||
|
"group": "Planning Center credentials"
|
||||||
|
},
|
||||||
|
"secret": {
|
||||||
|
"type": "password",
|
||||||
|
"label": "Secret",
|
||||||
|
"default": "",
|
||||||
|
"group": "Planning Center credentials"
|
||||||
|
},
|
||||||
|
"accessToken": {
|
||||||
|
"type": "password",
|
||||||
|
"label": "OAuth 2.0 access token (instead of Client ID + Secret)",
|
||||||
|
"default": "",
|
||||||
|
"group": "Planning Center credentials"
|
||||||
|
},
|
||||||
|
"serviceTypeId": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Service type",
|
||||||
|
"default": "",
|
||||||
|
"optionsRoute": "/service-types"
|
||||||
|
},
|
||||||
|
"planId": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Plan",
|
||||||
|
"default": "",
|
||||||
|
"options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }],
|
||||||
|
"optionsRoute": "/plans",
|
||||||
|
"optionsDependsOn": "serviceTypeId"
|
||||||
|
},
|
||||||
|
"pollSeconds": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Refresh Interval (s)",
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"followProPresenter": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Follow ProPresenter Module",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"preRollMinutes": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Pre-roll (min)",
|
||||||
|
"default": 30,
|
||||||
|
"group": "Service timing",
|
||||||
|
"help": "How long before a service's scheduled start the clock, item timing and countdown switch to it."
|
||||||
|
},
|
||||||
|
"useDocumentTriggers": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "ProPresenter document triggers",
|
||||||
|
"default": false,
|
||||||
|
"group": "Service timing"
|
||||||
|
},
|
||||||
|
"serviceStartTrigger": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Service start",
|
||||||
|
"default": "schedule",
|
||||||
|
"group": "Service timing",
|
||||||
|
"showWhen": "useDocumentTriggers",
|
||||||
|
"options": [
|
||||||
|
{ "value": "schedule", "label": "Scheduled service time" },
|
||||||
|
{ "value": "document", "label": "ProPresenter document" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"serviceStartDocument": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Start document",
|
||||||
|
"default": "",
|
||||||
|
"group": "Service timing",
|
||||||
|
"showWhen": "useDocumentTriggers",
|
||||||
|
"optionsRoute": "/documents",
|
||||||
|
"help": "The document that goes live as the service begins."
|
||||||
|
},
|
||||||
|
"startGraceMinutes": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Start failsafe (min)",
|
||||||
|
"default": 10,
|
||||||
|
"group": "Service timing",
|
||||||
|
"showWhen": "useDocumentTriggers",
|
||||||
|
"help": "Without the start document this long after the scheduled start, the service counts from the scheduled start anyway."
|
||||||
|
},
|
||||||
|
"serviceEndDocument": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "End document",
|
||||||
|
"default": "",
|
||||||
|
"group": "Service timing",
|
||||||
|
"showWhen": "useDocumentTriggers",
|
||||||
|
"optionsRoute": "/documents",
|
||||||
|
"help": "The document that goes live as the service ends; it freezes the service clock."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instanceGroups": {
|
||||||
|
"Top bar": {
|
||||||
|
"collapsed": false
|
||||||
|
},
|
||||||
|
"Colors": {
|
||||||
|
"collapsed": false,
|
||||||
|
"columns": 2
|
||||||
|
},
|
||||||
|
"Show / hide": {
|
||||||
|
"collapsed": true,
|
||||||
|
"columns": 3,
|
||||||
|
"help": "Switch off anything this tile should not show."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instanceSchema": {
|
||||||
|
"textSize": { "type": "number", "label": "Text size (px)", "default": 14 },
|
||||||
|
"autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true },
|
||||||
|
"noteCategories": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Item note categories to show (comma-separated, blank = all)",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"headerTemplate": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Top bar text",
|
||||||
|
"default": "{series} • {part} | {serviceType} • {date}",
|
||||||
|
"group": "Top bar",
|
||||||
|
"help": "Placeholders: {series} series name · {part} plan title / part number · {serviceType} · {date} · {shortDate} · {time} service start time · {length} planned length · {items} item count. Use • between items (an item whose placeholders are empty or hidden is dropped) and | to start a second line."
|
||||||
|
},
|
||||||
|
"keyColor": { "type": "color", "label": "Song key", "default": "#4ea1ff", "group": "Colors" },
|
||||||
|
"headerColor": { "type": "color", "label": "Section headers", "default": "#2ee59a", "group": "Colors" },
|
||||||
|
"currentColor": { "type": "color", "label": "Current item", "default": "#2ee59a", "group": "Colors" },
|
||||||
|
"headerGradient": { "type": "boolean", "label": "Fade header color to the right", "default": true, "group": "Colors" },
|
||||||
|
"tintByType": { "type": "boolean", "label": "Tint items by type (song / media / other)", "default": false, "group": "Colors" },
|
||||||
|
"showTopBar": { "type": "boolean", "label": "Top bar", "default": true, "group": "Show / hide" },
|
||||||
|
"showSeries": { "type": "boolean", "label": "Series name", "default": true, "group": "Show / hide" },
|
||||||
|
"showPart": { "type": "boolean", "label": "Part # / plan title", "default": true, "group": "Show / hide" },
|
||||||
|
"showServiceType": { "type": "boolean", "label": "Service type", "default": true, "group": "Show / hide" },
|
||||||
|
"showDate": { "type": "boolean", "label": "Plan date", "default": true, "group": "Show / hide" },
|
||||||
|
"showServiceTime": { "type": "boolean", "label": "Service time", "default": true, "group": "Show / hide" },
|
||||||
|
"showTotalLength": { "type": "boolean", "label": "Planned total length", "default": true, "group": "Show / hide" },
|
||||||
|
"showRunningClock": { "type": "boolean", "label": "Running clock (ahead / behind)", "default": true, "group": "Show / hide" },
|
||||||
|
"showCountdown": { "type": "boolean", "label": "Countdown to service start", "default": true, "group": "Show / hide" },
|
||||||
|
"showHeaders": { "type": "boolean", "label": "Section headers", "default": true, "group": "Show / hide" },
|
||||||
|
"showItemNumbers": { "type": "boolean", "label": "Item numbers", "default": false, "group": "Show / hide" },
|
||||||
|
"showStartTimes": { "type": "boolean", "label": "Scheduled start times", "default": true, "group": "Show / hide" },
|
||||||
|
"showLength": { "type": "boolean", "label": "Item length", "default": true, "group": "Show / hide" },
|
||||||
|
"showItemType": { "type": "boolean", "label": "Item type badge", "default": true, "group": "Show / hide" },
|
||||||
|
"showKey": { "type": "boolean", "label": "Song key", "default": true, "group": "Show / hide" },
|
||||||
|
"showArrangement": { "type": "boolean", "label": "Song arrangement", "default": false, "group": "Show / hide" },
|
||||||
|
"showSongAuthor": { "type": "boolean", "label": "Song author / CCLI", "default": false, "group": "Show / hide" },
|
||||||
|
"showDescription": { "type": "boolean", "label": "Item description", "default": true, "group": "Show / hide" },
|
||||||
|
"showNotes": { "type": "boolean", "label": "Item notes", "default": true, "group": "Show / hide" },
|
||||||
|
"showPreService": { "type": "boolean", "label": "Pre-service items", "default": true, "group": "Show / hide" },
|
||||||
|
"showPostService": { "type": "boolean", "label": "Post-service items", "default": true, "group": "Show / hide" },
|
||||||
|
"showNowIndicator": { "type": "boolean", "label": "Now indicator", "default": true, "group": "Show / hide" },
|
||||||
|
"showItemRuntime": { "type": "boolean", "label": "Item runtime (elapsed vs planned)", "default": true, "group": "Show / hide" },
|
||||||
|
"dimCompleted": { "type": "boolean", "label": "Dim completed items", "default": true, "group": "Show / hide" },
|
||||||
|
"showLiveFooter": { "type": "boolean", "label": "ProPresenter match line", "default": true, "group": "Show / hide" }
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service timing for the PCO Plan module — pure functions, no I/O, no
|
||||||
|
* timers. server.js feeds them the plan's service times, the documents
|
||||||
|
* ProPresenter puts live and the clock; service-timing.test.js calls them
|
||||||
|
* directly. Two questions are answered here:
|
||||||
|
*
|
||||||
|
* 1. Which of a plan's services owns the clock right now? → serviceWindows / selectService
|
||||||
|
* 2. When does that service count as started, and as ended? → advance
|
||||||
|
*
|
||||||
|
* ── Service windows ────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* A plan can have several services (plan_times of type "service": 9:00 and
|
||||||
|
* 11:00, say). Only ONE owns the service-elapsed clock, the current-item
|
||||||
|
* timing and the countdown at any moment, and moving into the next
|
||||||
|
* service's window resets all three. Each service's window runs from the
|
||||||
|
* end of the previous one to a boundary the next service's pre-roll sets:
|
||||||
|
*
|
||||||
|
* boundary(i → i+1) = min( start[i+1],
|
||||||
|
* max( start[i+1] − preRoll, endOf[i] ) )
|
||||||
|
*
|
||||||
|
* endOf[i] = the moment service i ended (its end cue fired), else its
|
||||||
|
* scheduled end (plan_time ends_at, else start + the planned
|
||||||
|
* length of the "during" items, else 90 min).
|
||||||
|
*
|
||||||
|
* In words: the next service takes over `preRoll` before its scheduled
|
||||||
|
* start (30 min by default; admin "Pre-roll"), but never before the current
|
||||||
|
* one has ended or was due to end, and never later than its own start. A
|
||||||
|
* service still running at the boundary is stopped there — that is the
|
||||||
|
* "next service's start" failsafe the end cue has. The first window opens at
|
||||||
|
* the beginning of time (before 9:00 the day belongs to the 9:00 service:
|
||||||
|
* countdown running, service idle); the last window never closes — the last
|
||||||
|
* service stays "stopped" until the plan itself changes (the next day's plan
|
||||||
|
* takes over through the plan picker). The last service also has a failsafe
|
||||||
|
* end: its scheduled end + LAST_SERVICE_OVERRUN_MS.
|
||||||
|
*
|
||||||
|
* ── Start and end of a service ─────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* phase: idle → running → stopped, never backwards within one window.
|
||||||
|
*
|
||||||
|
* start, schedule mode (default) at the scheduled start; startedAt = it.
|
||||||
|
* start, document mode when the start document goes live
|
||||||
|
* (edge: it has to *become* live, from
|
||||||
|
* pre-roll before the scheduled start
|
||||||
|
* onwards — a rehearsal an hour earlier
|
||||||
|
* that shows the same document is not the
|
||||||
|
* service). Failsafe: if the document has
|
||||||
|
* not gone live by start + grace (admin
|
||||||
|
* "Start failsafe", 10 min default), the
|
||||||
|
* service starts anyway, counting from
|
||||||
|
* the scheduled start — the best estimate
|
||||||
|
* of when it really began.
|
||||||
|
* end, document when the end document goes live while
|
||||||
|
* the service is running (an end document
|
||||||
|
* live before the start — the previous
|
||||||
|
* service's loop — is ignored). Failsafe
|
||||||
|
* either way: the window boundary (see
|
||||||
|
* above), or, for the last service, its
|
||||||
|
* scheduled end + LAST_SERVICE_OVERRUN_MS.
|
||||||
|
*
|
||||||
|
* Cue documents are matched with matching.isCue (TRIGGER_THRESHOLD, see
|
||||||
|
* matching.js for the tolerance), so "Post-Service Loop" still ends the
|
||||||
|
* service when ProPresenter calls it "Post Service Loop".
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { similarity, TRIGGER_THRESHOLD } = require('./matching');
|
||||||
|
|
||||||
|
const MINUTE = 60000;
|
||||||
|
/** How long before a service's scheduled start the timers switch to it (admin "Pre-roll"). */
|
||||||
|
const DEFAULT_PRE_ROLL_MS = 30 * MINUTE;
|
||||||
|
/** Document mode: start by the schedule anyway this long after the scheduled start (admin "Start failsafe"). */
|
||||||
|
const DEFAULT_START_GRACE_MS = 10 * MINUTE;
|
||||||
|
/** The last service of a plan is stopped this long after its scheduled end if nothing ended it. */
|
||||||
|
const LAST_SERVICE_OVERRUN_MS = 60 * MINUTE;
|
||||||
|
/** A service with no end time and no planned item lengths is assumed this long. */
|
||||||
|
const DEFAULT_SERVICE_LENGTH_MS = 90 * MINUTE;
|
||||||
|
|
||||||
|
/** The plan times that are services (every timed one when none is typed "service"), earliest first. */
|
||||||
|
function serviceTimes(planTimes) {
|
||||||
|
const list = Array.isArray(planTimes) ? planTimes.filter((t) => t && Number(t.startsAt) > 0) : [];
|
||||||
|
const services = list.filter((t) => t.timeType === 'service');
|
||||||
|
return (services.length ? services : list).slice().sort((a, b) => a.startsAt - b.startsAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window each service owns (see the file comment).
|
||||||
|
*
|
||||||
|
* @param {Array} planTimes [{ id, name, timeType, startsAt, endsAt }] (ms epochs)
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {number} [opts.preRollMs] how early the next service may take over (default 30 min)
|
||||||
|
* @param {number} [opts.plannedMs] planned length of the service — for services without ends_at
|
||||||
|
* @param {object} [opts.endedAt] { [serviceId]: ms } services already ended by their cue
|
||||||
|
* @returns {Array<{ id, name, index, count, startsAt, endsAt, windowFrom, windowUntil, cueFrom, failsafeEndAt, isLast }>}
|
||||||
|
*/
|
||||||
|
function serviceWindows(planTimes, opts = {}) {
|
||||||
|
const preRoll = Number.isFinite(opts.preRollMs) ? Math.max(0, opts.preRollMs) : DEFAULT_PRE_ROLL_MS;
|
||||||
|
const planned = Number(opts.plannedMs) > 0 ? Number(opts.plannedMs) : 0;
|
||||||
|
const ended = opts.endedAt && typeof opts.endedAt === 'object' ? opts.endedAt : {};
|
||||||
|
const services = serviceTimes(planTimes);
|
||||||
|
const windows = services.map((svc, i) => {
|
||||||
|
const scheduledEnd = svc.endsAt > svc.startsAt ? svc.endsAt : svc.startsAt + (planned || DEFAULT_SERVICE_LENGTH_MS);
|
||||||
|
const endOf = Number(ended[svc.id]) > 0 ? Number(ended[svc.id]) : scheduledEnd;
|
||||||
|
const next = services[i + 1];
|
||||||
|
const windowUntil = next ? Math.min(next.startsAt, Math.max(next.startsAt - preRoll, endOf)) : Infinity;
|
||||||
|
return {
|
||||||
|
id: String(svc.id),
|
||||||
|
name: String(svc.name || ''),
|
||||||
|
index: i,
|
||||||
|
count: services.length,
|
||||||
|
startsAt: svc.startsAt,
|
||||||
|
endsAt: scheduledEnd,
|
||||||
|
windowFrom: -Infinity, // filled in below
|
||||||
|
windowUntil,
|
||||||
|
cueFrom: svc.startsAt - preRoll, // a start document counts from here on
|
||||||
|
failsafeEndAt: next ? windowUntil : scheduledEnd + LAST_SERVICE_OVERRUN_MS,
|
||||||
|
isLast: !next,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
for (let i = 1; i < windows.length; i += 1) windows[i].windowFrom = windows[i - 1].windowUntil;
|
||||||
|
return windows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The window that holds `now` — null only when there are no services. */
|
||||||
|
function selectService(windows, now) {
|
||||||
|
if (!Array.isArray(windows) || !windows.length) return null;
|
||||||
|
return windows.find((w) => now >= w.windowFrom && now < w.windowUntil) || windows[windows.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Timing state for a service that has just come into its window. */
|
||||||
|
function freshService(window) {
|
||||||
|
return {
|
||||||
|
id: window ? String(window.id) : '',
|
||||||
|
phase: window ? 'idle' : 'none',
|
||||||
|
startedAt: 0,
|
||||||
|
endedAt: 0,
|
||||||
|
startTrigger: '', // 'schedule' | 'document' | 'failsafe'
|
||||||
|
endTrigger: '', // 'document' | 'next-service' | 'plan-end'
|
||||||
|
cue: '', // the document that started / ended it
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(ms) {
|
||||||
|
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a service's timing on by a clock tick or by a document going live.
|
||||||
|
* Pure: returns a new state and a one-line description of what happened
|
||||||
|
* (for the log), or `changed: false`.
|
||||||
|
*
|
||||||
|
* @param {object} svc state from freshService(window) / a previous advance()
|
||||||
|
* @param {object} window from serviceWindows()
|
||||||
|
* @param {object} opts { startMode: 'schedule'|'document', startDocument, endDocument, startGraceMs }
|
||||||
|
* @param {number} now ms epoch
|
||||||
|
* @param {string} [cue] the document that just went live (omit on a plain tick)
|
||||||
|
* @returns {{ svc: object, changed: boolean, event: string }}
|
||||||
|
*/
|
||||||
|
function advance(svc, window, opts, now, cue) {
|
||||||
|
const o = opts || {};
|
||||||
|
const grace = Number.isFinite(o.startGraceMs) ? Math.max(0, o.startGraceMs) : DEFAULT_START_GRACE_MS;
|
||||||
|
const cueName = String(cue || '').trim();
|
||||||
|
const same = { svc, changed: false, event: '' };
|
||||||
|
if (!svc || !window || svc.id !== String(window.id)) return same;
|
||||||
|
const at = fmtTime(window.startsAt);
|
||||||
|
|
||||||
|
if (svc.phase === 'idle') {
|
||||||
|
const documentMode = o.startMode === 'document' && o.startDocument;
|
||||||
|
if (documentMode) {
|
||||||
|
if (cueName && now >= window.cueFrom) {
|
||||||
|
const score = similarity(cueName, o.startDocument);
|
||||||
|
if (score >= TRIGGER_THRESHOLD) {
|
||||||
|
return {
|
||||||
|
svc: { ...svc, phase: 'running', startedAt: now, startTrigger: 'document', cue: cueName },
|
||||||
|
changed: true,
|
||||||
|
event: `started at ${fmtTime(now)} — "${cueName}" went live (start document "${o.startDocument}", match ${score.toFixed(2)})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (now >= window.startsAt + grace) {
|
||||||
|
return {
|
||||||
|
svc: { ...svc, phase: 'running', startedAt: window.startsAt, startTrigger: 'failsafe' },
|
||||||
|
changed: true,
|
||||||
|
event: `started (failsafe) — the start document "${o.startDocument}" did not go live within ${Math.round(grace / MINUTE)} min of ${at}; counting from ${at}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return same;
|
||||||
|
}
|
||||||
|
if (now >= window.startsAt) {
|
||||||
|
return {
|
||||||
|
svc: { ...svc, phase: 'running', startedAt: window.startsAt, startTrigger: 'schedule' },
|
||||||
|
changed: true,
|
||||||
|
event: `started — scheduled ${at}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return same;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (svc.phase === 'running') {
|
||||||
|
if (o.endDocument && cueName) {
|
||||||
|
const score = similarity(cueName, o.endDocument);
|
||||||
|
if (score >= TRIGGER_THRESHOLD) {
|
||||||
|
return {
|
||||||
|
svc: { ...svc, phase: 'stopped', endedAt: now, endTrigger: 'document', cue: cueName },
|
||||||
|
changed: true,
|
||||||
|
event: `ended at ${fmtTime(now)} — "${cueName}" went live (end document "${o.endDocument}", match ${score.toFixed(2)})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (now >= window.failsafeEndAt) {
|
||||||
|
const endedAt = Math.min(now, window.failsafeEndAt);
|
||||||
|
return {
|
||||||
|
svc: { ...svc, phase: 'stopped', endedAt, endTrigger: window.isLast ? 'plan-end' : 'next-service' },
|
||||||
|
changed: true,
|
||||||
|
event: window.isLast
|
||||||
|
? `stopped (failsafe) — ${Math.round(LAST_SERVICE_OVERRUN_MS / MINUTE)} min past its scheduled end ${fmtTime(window.endsAt)}`
|
||||||
|
: `stopped (failsafe) — the next service's window opened at ${fmtTime(endedAt)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return same;
|
||||||
|
}
|
||||||
|
|
||||||
|
return same;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MINUTE,
|
||||||
|
DEFAULT_PRE_ROLL_MS,
|
||||||
|
DEFAULT_START_GRACE_MS,
|
||||||
|
LAST_SERVICE_OVERRUN_MS,
|
||||||
|
DEFAULT_SERVICE_LENGTH_MS,
|
||||||
|
serviceTimes,
|
||||||
|
serviceWindows,
|
||||||
|
selectService,
|
||||||
|
freshService,
|
||||||
|
advance,
|
||||||
|
fmtTime,
|
||||||
|
};
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the PCO Plan module's pure service-timing logic.
|
||||||
|
*
|
||||||
|
* node modules/pco-plan/service-timing.test.js
|
||||||
|
*
|
||||||
|
* Plain Node, no dependencies; exits non-zero on the first failure. Times are
|
||||||
|
* built for a two-service Sunday (9:00 and 11:00, 75 min each, 30 min
|
||||||
|
* pre-roll) so the expectations read like the day would.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const { similarity, isCue, MATCH_THRESHOLD, TRIGGER_THRESHOLD } = require('./matching');
|
||||||
|
const {
|
||||||
|
MINUTE,
|
||||||
|
LAST_SERVICE_OVERRUN_MS,
|
||||||
|
serviceWindows,
|
||||||
|
selectService,
|
||||||
|
freshService,
|
||||||
|
advance,
|
||||||
|
} = require('./service-timing');
|
||||||
|
|
||||||
|
const DAY = new Date(2026, 8, 13, 0, 0, 0, 0).getTime(); // a local midnight
|
||||||
|
const at = (h, m = 0) => DAY + (h * 60 + m) * MINUTE;
|
||||||
|
const hm = (ms) => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||||
|
|
||||||
|
const PLAN_TIMES = [
|
||||||
|
{ id: 'r', name: 'Rehearsal', timeType: 'rehearsal', startsAt: at(8, 0), endsAt: at(8, 45) },
|
||||||
|
{ id: 's1', name: 'First Service', timeType: 'service', startsAt: at(9, 0), endsAt: at(10, 15) },
|
||||||
|
{ id: 's2', name: 'Second Service', timeType: 'service', startsAt: at(11, 0), endsAt: at(12, 15) },
|
||||||
|
];
|
||||||
|
const PRE_ROLL = 30 * MINUTE;
|
||||||
|
const OPTS = { preRollMs: PRE_ROLL };
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
passed += 1;
|
||||||
|
console.log(` ok ${name}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` FAIL ${name}\n ${err.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('service windows');
|
||||||
|
|
||||||
|
test('rehearsal is not a service; windows are contiguous and ordered', () => {
|
||||||
|
const w = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
assert.deepStrictEqual(w.map((x) => x.id), ['s1', 's2']);
|
||||||
|
assert.strictEqual(w[0].windowFrom, -Infinity);
|
||||||
|
assert.strictEqual(w[0].windowUntil, w[1].windowFrom);
|
||||||
|
assert.strictEqual(w[1].windowUntil, Infinity);
|
||||||
|
assert.strictEqual(w[0].count, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the boundary is 30 min before the next start once the first was due to end', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
// 10:15 scheduled end, 11:00 − 30 = 10:30 → boundary 10:30
|
||||||
|
assert.strictEqual(hm(w1.windowUntil), hm(at(10, 30)));
|
||||||
|
assert.strictEqual(w1.failsafeEndAt, w1.windowUntil, 'first service failsafe-stops at the boundary');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pre-roll larger than the gap waits for the scheduled end, never passes the next start', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, { preRollMs: 60 * MINUTE });
|
||||||
|
assert.strictEqual(hm(w1.windowUntil), hm(at(10, 15)), 'max(10:00, 10:15)');
|
||||||
|
const [tight] = serviceWindows(
|
||||||
|
[
|
||||||
|
{ id: 'a', timeType: 'service', startsAt: at(9, 0), endsAt: at(10, 30) },
|
||||||
|
{ id: 'b', timeType: 'service', startsAt: at(10, 0), endsAt: at(11, 0) },
|
||||||
|
],
|
||||||
|
OPTS
|
||||||
|
);
|
||||||
|
assert.strictEqual(hm(tight.windowUntil), hm(at(10, 0)), 'capped at the next start');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an early actual end lets the next window open at pre-roll', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, { preRollMs: 60 * MINUTE, endedAt: { s1: at(10, 5) } });
|
||||||
|
assert.strictEqual(hm(w1.windowUntil), hm(at(10, 5)), 'ended at 10:05 inside the 10:00 pre-roll → flips at 10:05');
|
||||||
|
const [w1b] = serviceWindows(PLAN_TIMES, { preRollMs: PRE_ROLL, endedAt: { s1: at(10, 5) } });
|
||||||
|
assert.strictEqual(hm(w1b.windowUntil), hm(at(10, 30)), 'ended at 10:05 before the 10:30 pre-roll → flips at 10:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a service without ends_at gets the planned length, else 90 min', () => {
|
||||||
|
const [w] = serviceWindows([{ id: 'x', timeType: 'service', startsAt: at(9, 0), endsAt: 0 }], { plannedMs: 70 * MINUTE });
|
||||||
|
assert.strictEqual(hm(w.endsAt), hm(at(10, 10)));
|
||||||
|
const [w90] = serviceWindows([{ id: 'x', timeType: 'service', startsAt: at(9, 0), endsAt: 0 }], {});
|
||||||
|
assert.strictEqual(hm(w90.endsAt), hm(at(10, 30)));
|
||||||
|
assert.strictEqual(w90.failsafeEndAt, w90.endsAt + LAST_SERVICE_OVERRUN_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectService: 8:50 → 9:00, 9:30 → 9:00, 10:55 → 11:00, 11:05 → 11:00', () => {
|
||||||
|
const w = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
assert.strictEqual(selectService(w, at(8, 50)).id, 's1');
|
||||||
|
assert.strictEqual(selectService(w, at(9, 30)).id, 's1');
|
||||||
|
assert.strictEqual(selectService(w, at(10, 29)).id, 's1', 'still the first service until the 10:30 boundary');
|
||||||
|
assert.strictEqual(selectService(w, at(10, 55)).id, 's2');
|
||||||
|
assert.strictEqual(selectService(w, at(11, 5)).id, 's2');
|
||||||
|
assert.strictEqual(selectService(w, at(6, 0)).id, 's1', 'the morning belongs to the first service');
|
||||||
|
assert.strictEqual(selectService(w, at(15, 0)).id, 's2', 'the afternoon stays with the last service');
|
||||||
|
assert.strictEqual(selectService([], at(9, 0)), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('service start');
|
||||||
|
|
||||||
|
const SCHEDULE = { startMode: 'schedule', startDocument: '', endDocument: '', startGraceMs: 10 * MINUTE };
|
||||||
|
const DOCUMENT = {
|
||||||
|
startMode: 'document',
|
||||||
|
startDocument: 'Welcome & Announcements',
|
||||||
|
endDocument: 'Post Service Loop',
|
||||||
|
startGraceMs: 10 * MINUTE,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('schedule mode: idle before 9:00, running from 9:00 counting from 9:00; documents ignored', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
let s = freshService(w1);
|
||||||
|
assert.strictEqual(advance(s, w1, SCHEDULE, at(8, 59), 'Welcome').changed, false, 'a document is not a cue in schedule mode');
|
||||||
|
assert.strictEqual(advance(s, w1, SCHEDULE, at(8, 59)).changed, false);
|
||||||
|
const out = advance(s, w1, SCHEDULE, at(9, 0, 30));
|
||||||
|
assert.strictEqual(out.changed, true);
|
||||||
|
s = out.svc;
|
||||||
|
assert.strictEqual(s.phase, 'running');
|
||||||
|
assert.strictEqual(s.startedAt, at(9, 0), 'counts from the scheduled start, not the tick that noticed');
|
||||||
|
assert.strictEqual(s.startTrigger, 'schedule');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('document mode: the start document going live starts the service at that moment', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
const s = freshService(w1);
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 0, 30)).changed, false, 'the scheduled time alone does not start it');
|
||||||
|
const out = advance(s, w1, DOCUMENT, at(8, 58), 'Welcome and Announcements [Full]');
|
||||||
|
assert.strictEqual(out.changed, true, 'a renamed / tagged start document still counts');
|
||||||
|
assert.strictEqual(out.svc.phase, 'running');
|
||||||
|
assert.strictEqual(out.svc.startedAt, at(8, 58), 'an early start is the real start');
|
||||||
|
assert.strictEqual(out.svc.startTrigger, 'document');
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 1), 'Post Service Loop').changed, false, 'the end document does not start an idle service');
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 1), 'Goodness of God').changed, false, 'an unrelated document does nothing');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('document mode: the start document is only a cue from pre-roll before the start', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
const s = freshService(w1);
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(8, 0), 'Welcome & Announcements').changed, false, 'rehearsal at 8:00 shows it — not the service');
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(8, 30), 'Welcome & Announcements').changed, true, 'from 8:30 it counts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('document mode: failsafe at start + grace counts from the scheduled start', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
const s = freshService(w1);
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 9, 59)).changed, false);
|
||||||
|
const out = advance(s, w1, DOCUMENT, at(9, 10));
|
||||||
|
assert.strictEqual(out.changed, true);
|
||||||
|
assert.strictEqual(out.svc.phase, 'running');
|
||||||
|
assert.strictEqual(out.svc.startedAt, at(9, 0));
|
||||||
|
assert.strictEqual(out.svc.startTrigger, 'failsafe');
|
||||||
|
assert.match(out.event, /failsafe/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('document mode: a cue and the failsafe in the same tick — the cue wins', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
const out = advance(freshService(w1), w1, DOCUMENT, at(9, 12), 'Welcome & Announcements');
|
||||||
|
assert.strictEqual(out.svc.startTrigger, 'document');
|
||||||
|
assert.strictEqual(out.svc.startedAt, at(9, 12));
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('service end');
|
||||||
|
|
||||||
|
test('the end document freezes a running service; a second start document is ignored', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
let s = advance(freshService(w1), w1, DOCUMENT, at(9, 0), 'Welcome & Announcements').svc;
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 40), 'Welcome & Announcements').changed, false, 'shown again mid-service: nothing');
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 40), 'Pre Service Loop').changed, false, 'the PRE-service loop must not end it (0.67 < 0.75)');
|
||||||
|
const out = advance(s, w1, DOCUMENT, at(10, 12), 'Post-Service Loop');
|
||||||
|
assert.strictEqual(out.changed, true, 'a hyphenated end document still counts');
|
||||||
|
s = out.svc;
|
||||||
|
assert.strictEqual(s.phase, 'stopped');
|
||||||
|
assert.strictEqual(s.endedAt, at(10, 12));
|
||||||
|
assert.strictEqual(s.endTrigger, 'document');
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(10, 20), 'Welcome & Announcements').changed, false, 'stopped stays stopped');
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(10, 45)).changed, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failsafe: a service still running when the next window opens stops at the boundary', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
const s = advance(freshService(w1), w1, SCHEDULE, at(9, 0)).svc;
|
||||||
|
assert.strictEqual(advance(s, w1, SCHEDULE, at(10, 29)).changed, false, 'running past its 10:15 end is allowed');
|
||||||
|
const out = advance(s, w1, SCHEDULE, at(10, 31));
|
||||||
|
assert.strictEqual(out.changed, true);
|
||||||
|
assert.strictEqual(out.svc.phase, 'stopped');
|
||||||
|
assert.strictEqual(hm(out.svc.endedAt), hm(at(10, 30)), 'frozen at the boundary, not at the tick');
|
||||||
|
assert.strictEqual(out.svc.endTrigger, 'next-service');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failsafe: the last service stops an hour after its scheduled end', () => {
|
||||||
|
const [, w2] = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
const s = advance(freshService(w2), w2, SCHEDULE, at(11, 0)).svc;
|
||||||
|
assert.strictEqual(advance(s, w2, SCHEDULE, at(13, 14)).changed, false);
|
||||||
|
const out = advance(s, w2, SCHEDULE, at(13, 15));
|
||||||
|
assert.strictEqual(out.changed, true);
|
||||||
|
assert.strictEqual(out.svc.phase, 'stopped');
|
||||||
|
assert.strictEqual(hm(out.svc.endedAt), hm(at(13, 15)));
|
||||||
|
assert.strictEqual(out.svc.endTrigger, 'plan-end');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the end document does not stop a service in the wrong window', () => {
|
||||||
|
const w = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
const s1 = advance(freshService(w[0]), w[0], DOCUMENT, at(9, 0), 'Welcome & Announcements').svc;
|
||||||
|
assert.strictEqual(advance(s1, w[1], DOCUMENT, at(10, 40), 'Post Service Loop').changed, false, 'state and window must belong together');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('matching');
|
||||||
|
|
||||||
|
test('plan items: 0.6 — two of three words; cues: 0.75 — pre/post loops stay apart', () => {
|
||||||
|
assert.strictEqual(MATCH_THRESHOLD, 0.6);
|
||||||
|
assert.strictEqual(TRIGGER_THRESHOLD, 0.75);
|
||||||
|
assert.strictEqual(similarity('Post-Service Loop', 'Post Service Loop'), 1);
|
||||||
|
assert.strictEqual(similarity('Post Service Loop [Full]', 'Post Service Loop'), 1);
|
||||||
|
assert.strictEqual(similarity('Post Service', 'Post Service Loop'), 0.85);
|
||||||
|
assert.ok(Math.abs(similarity('Pre Service Loop', 'Post Service Loop') - 2 / 3) < 0.001);
|
||||||
|
assert.strictEqual(isCue('Pre Service Loop', 'Post Service Loop'), false);
|
||||||
|
assert.strictEqual(isCue('Post-Service Loop', 'Post Service Loop'), true);
|
||||||
|
assert.strictEqual(isCue('Welcome', 'Welcome & Announcements'), true);
|
||||||
|
assert.strictEqual(isCue('Anything', ''), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n${passed} tests passed`);
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
/* PCO Plan module — scoped to its tiles, shell variables only (plus the
|
||||||
|
admin-chosen --pp-key color for song keys). Everything scales from
|
||||||
|
--pp-size (set from the tile's text-size setting). */
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-wrap {
|
||||||
|
--pp-size: 14px;
|
||||||
|
--pp-key: #4ea1ff;
|
||||||
|
--pp-header: var(--accent);
|
||||||
|
--pp-current: var(--accent);
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: var(--pp-size);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── top bar ── */
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-head {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1em;
|
||||||
|
padding: 0.6em 0.85em 0.5em;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-head[hidden] { display: none; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-plan { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-plan-title {
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-plan-sub {
|
||||||
|
margin-top: 0.15em;
|
||||||
|
font-size: 0.82em;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-plan-sub[hidden] { display: none; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-clock {
|
||||||
|
flex: none;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-clock-label {
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-clock-value {
|
||||||
|
font-size: 1.25em;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-clock-value:empty,
|
||||||
|
[data-module="pco-plan"] .pp-clock-label:empty { display: none; }
|
||||||
|
[data-module="pco-plan"] .pp-clock-value.is-behind { color: var(--warn); }
|
||||||
|
[data-module="pco-plan"] .pp-clock-value.is-ahead { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── list ── */
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 0.35em 0.5em 0.6em;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scroll-padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.6em;
|
||||||
|
padding: 0.45em 0.5em;
|
||||||
|
border-radius: 0.5em;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: background 0.2s ease, opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-row + .pp-row { margin-top: 0.15em; }
|
||||||
|
[data-module="pco-plan"] .pp-row:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
|
||||||
|
|
||||||
|
/* Optional background tint per item type (admin "Background color per item type"). */
|
||||||
|
[data-module="pco-plan"] .tint-types .pp-row.is-song { background: color-mix(in srgb, var(--accent) 9%, transparent); }
|
||||||
|
[data-module="pco-plan"] .tint-types .pp-row.is-media { background: color-mix(in srgb, var(--warn) 10%, transparent); }
|
||||||
|
[data-module="pco-plan"] .tint-types .pp-row.is-item { background: color-mix(in srgb, var(--text) 5%, transparent); }
|
||||||
|
[data-module="pco-plan"] .tint-types .pp-row.is-song:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }
|
||||||
|
[data-module="pco-plan"] .tint-types .pp-row.is-media:hover { background: color-mix(in srgb, var(--warn) 15%, transparent); }
|
||||||
|
[data-module="pco-plan"] .tint-types .pp-row.is-item:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
|
||||||
|
|
||||||
|
/* Section headers: a band that runs edge to edge across the tile (negative
|
||||||
|
margins cancel the list padding) so a new part of the service is
|
||||||
|
unmistakable — tile-chosen color, solid or fading to the right. The
|
||||||
|
left padding keeps the text aligned with the other rows' text. */
|
||||||
|
[data-module="pco-plan"] .pp-row.is-header {
|
||||||
|
margin: 0.9em -0.5em 0;
|
||||||
|
padding: 0.5em 1em 0.45em calc(1em + 3px);
|
||||||
|
align-items: center;
|
||||||
|
border-left: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: color-mix(in srgb, var(--pp-header) 22%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-row.is-header:first-child { margin-top: 0; }
|
||||||
|
[data-module="pco-plan"] .pp-row + .pp-row.is-header { margin-top: 0.9em; }
|
||||||
|
[data-module="pco-plan"] .pp-row.is-header:hover { background: color-mix(in srgb, var(--pp-header) 28%, transparent); }
|
||||||
|
[data-module="pco-plan"] .header-gradient .pp-row.is-header {
|
||||||
|
background: linear-gradient(90deg, color-mix(in srgb, var(--pp-header) 30%, transparent), color-mix(in srgb, var(--pp-header) 6%, transparent) 70%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .header-gradient .pp-row.is-header:hover {
|
||||||
|
background: linear-gradient(90deg, color-mix(in srgb, var(--pp-header) 36%, transparent), color-mix(in srgb, var(--pp-header) 8%, transparent) 70%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-row.is-header .pp-title {
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-row.is-header .pp-col-time,
|
||||||
|
[data-module="pco-plan"] .pp-row.is-header .pp-col-num,
|
||||||
|
[data-module="pco-plan"] .pp-row.is-header .pp-col-len { visibility: hidden; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-row.is-pre .pp-title,
|
||||||
|
[data-module="pco-plan"] .pp-row.is-post .pp-title { color: color-mix(in srgb, var(--text) 75%, var(--muted)); }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-row.is-done { opacity: 0.45; }
|
||||||
|
[data-module="pco-plan"] .pp-row.is-done:hover { opacity: 0.8; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-row.is-current {
|
||||||
|
border-left-color: var(--pp-current);
|
||||||
|
background: color-mix(in srgb, var(--pp-current) 16%, transparent);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-col-num {
|
||||||
|
flex: none;
|
||||||
|
width: 1.6em;
|
||||||
|
padding-top: 0.1em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-col-time {
|
||||||
|
flex: none;
|
||||||
|
width: 4.6em;
|
||||||
|
padding-top: 0.1em;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-row.is-current .pp-col-time { color: var(--pp-current); font-weight: 700; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-col-main { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-title-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.2em 0.55em;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-title {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The song key: right after the title, larger and in the admin-chosen
|
||||||
|
color (blue by default) so the band can read it from across the booth. */
|
||||||
|
[data-module="pco-plan"] .pp-key {
|
||||||
|
font-size: 1.15em;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
padding: 0.05em 0.5em;
|
||||||
|
border-radius: 0.4em;
|
||||||
|
color: var(--pp-key);
|
||||||
|
background: color-mix(in srgb, var(--pp-key) 16%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--pp-key) 45%, transparent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-now-tag {
|
||||||
|
display: none;
|
||||||
|
padding: 0.05em 0.45em;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--pp-current);
|
||||||
|
color: var(--bg);
|
||||||
|
font-size: 0.65em;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-row.is-current .pp-now-tag { display: inline-block; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-badge,
|
||||||
|
[data-module="pco-plan"] .pp-arr {
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.1em 0.45em;
|
||||||
|
border-radius: 0.35em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-badge {
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-badge.is-song { color: color-mix(in srgb, var(--accent) 80%, var(--text)); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
|
||||||
|
[data-module="pco-plan"] .pp-badge.is-media { color: color-mix(in srgb, var(--warn) 85%, var(--text)); border-color: color-mix(in srgb, var(--warn) 40%, transparent); }
|
||||||
|
[data-module="pco-plan"] .pp-arr { color: var(--muted); text-transform: none; letter-spacing: 0; font-weight: 600; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-author {
|
||||||
|
margin-top: 0.1em;
|
||||||
|
font-size: 0.78em;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-desc {
|
||||||
|
margin-top: 0.15em;
|
||||||
|
font-size: 0.86em;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: color-mix(in srgb, var(--text) 80%, var(--muted));
|
||||||
|
white-space: pre-line;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-notes {
|
||||||
|
margin-top: 0.3em;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2em;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-note {
|
||||||
|
font-size: 0.82em;
|
||||||
|
line-height: 1.35;
|
||||||
|
padding: 0.25em 0.5em;
|
||||||
|
border-radius: 0.35em;
|
||||||
|
background: color-mix(in srgb, var(--warn) 9%, transparent);
|
||||||
|
border-left: 2px solid color-mix(in srgb, var(--warn) 60%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
white-space: pre-line;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-note-cat {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-col-len {
|
||||||
|
flex: none;
|
||||||
|
min-width: 3.2em;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-len {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--muted);
|
||||||
|
padding-top: 0.1em;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-elapsed {
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-elapsed:empty { display: none; }
|
||||||
|
[data-module="pco-plan"] .pp-elapsed.is-running { color: var(--accent); opacity: 1; }
|
||||||
|
[data-module="pco-plan"] .pp-elapsed.is-over { color: var(--warn); opacity: 1; }
|
||||||
|
[data-module="pco-plan"] .pp-elapsed.is-over-hard { color: var(--danger); opacity: 1; }
|
||||||
|
|
||||||
|
/* ── empty / degraded states ── */
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-empty {
|
||||||
|
padding: 12% 1.2em 1em;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-empty-title {
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-empty-hint {
|
||||||
|
margin-top: 0.4em;
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-empty-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.9em;
|
||||||
|
padding: 0.35em 0.9em;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-empty-link:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-wrap.is-offline .pp-head { border-bottom-color: color-mix(in srgb, var(--danger) 50%, transparent); }
|
||||||
|
[data-module="pco-plan"] .pp-wrap.is-offline .pp-list { opacity: 0.6; }
|
||||||
|
|
||||||
|
/* ── footer: ProPresenter match line ── */
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-foot {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
padding: 0.4em 0.85em;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-foot[hidden] { display: none; }
|
||||||
|
[data-module="pco-plan"] .pp-foot::before {
|
||||||
|
content: '';
|
||||||
|
flex: none;
|
||||||
|
width: 0.55em;
|
||||||
|
height: 0.55em;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-foot.is-live { color: var(--text); }
|
||||||
|
[data-module="pco-plan"] .pp-foot.is-live::before { background: var(--accent); box-shadow: 0 0 0 0.2em color-mix(in srgb, var(--accent) 18%, transparent); }
|
||||||
|
[data-module="pco-plan"] .pp-foot.is-unmatched { color: var(--warn); }
|
||||||
|
[data-module="pco-plan"] .pp-foot.is-unmatched::before { background: var(--warn); }
|
||||||
|
[data-module="pco-plan"] .pp-foot.is-down { color: var(--danger); }
|
||||||
|
[data-module="pco-plan"] .pp-foot.is-down::before { background: var(--danger); }
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
Ported from prodcom-listener/public/app.js into the ProdDash module
|
Ported from prodcom-listener/public/app.js into the ProdDash module
|
||||||
contract: everything lives inside the tile's root, per-tile state
|
contract: everything lives inside the tile's root, per-tile state
|
||||||
(hidden channels, timestamps, text size) persists in instance settings,
|
(hidden channels, channel icons, timestamps, text size, flow direction)
|
||||||
and all network traffic goes through moduleApi (the module's server
|
persists in instance settings, and all network traffic goes through
|
||||||
proxy at /prodcom/*). */
|
moduleApi (the module's server proxy at /prodcom/*). */
|
||||||
|
|
||||||
export default function create({ root, moduleApi }) {
|
export default function create({ root, moduleApi }) {
|
||||||
/* ── per-instance state ─────────────────────────────────────────── */
|
/* ── per-instance state ─────────────────────────────────────────── */
|
||||||
@@ -12,7 +12,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
const channels = new Map(); // channelId -> {name, color}
|
const channels = new Map(); // channelId -> {name, color}
|
||||||
const groups = new Map(); // groupId -> {name, channelIds}
|
const groups = new Map(); // groupId -> {name, channelIds}
|
||||||
const entryEls = new Map(); // entryId -> element
|
const entryEls = new Map(); // entryId -> element
|
||||||
let pinnedToBottom = true;
|
let pinnedToLatest = true;
|
||||||
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
|
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
|
||||||
let stream = null; // moduleApi.sse handle
|
let stream = null; // moduleApi.sse handle
|
||||||
let bootTimer = null;
|
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 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 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 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)
|
let channelMenuEl = null; // the open menu's element (filled on each open)
|
||||||
const channelsMenu = moduleApi.header.addMenu({
|
const channelsMenu = moduleApi.header.addMenu({
|
||||||
label: 'Channels ▾',
|
icon: CHANNELS_SVG,
|
||||||
title: 'Choose visible channels',
|
title: 'Choose visible channels',
|
||||||
build(menu) {
|
build(menu) {
|
||||||
menu.classList.add('pt-menu');
|
menu.classList.add('pt-menu');
|
||||||
@@ -52,6 +56,11 @@ export default function create({ root, moduleApi }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
channelsMenu.button.classList.add('pt-channels-btn');
|
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;
|
let timeBtn = null;
|
||||||
timeBtn = moduleApi.header.addButton({
|
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: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
||||||
moduleApi.header.addButton({ label: 'A+', title: 'Larger 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({
|
moduleApi.header.addButton({
|
||||||
icon: JUMP_SVG,
|
icon: JUMP_SVG,
|
||||||
title: 'Jump to latest',
|
title: 'Jump to latest',
|
||||||
onClick() {
|
onClick() {
|
||||||
pinnedToBottom = true;
|
pinnedToLatest = true;
|
||||||
jumpBtn.hidden = 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));
|
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
||||||
wrap.style.setProperty('--pt-size', size + 'px');
|
wrap.style.setProperty('--pt-size', size + 'px');
|
||||||
wrap.classList.toggle('show-times', Boolean(s.showTimes));
|
wrap.classList.toggle('show-times', Boolean(s.showTimes));
|
||||||
|
wrap.classList.toggle('hide-avatars', s.showAvatars === false);
|
||||||
timeBtn?.classList.toggle('active', Boolean(s.showTimes));
|
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();
|
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 ─────────────────────────────────── */
|
/* ── channel visibility toggles ─────────────────────────────────── */
|
||||||
|
|
||||||
function persistHidden() {
|
function persistHidden() {
|
||||||
@@ -119,9 +134,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyFilter(el) {
|
function applyFilter(el) {
|
||||||
const hidden = hiddenChannels.has(el.dataset.channelId)
|
el.classList.toggle('hidden-by-filter', hiddenChannels.has(el.dataset.channelId));
|
||||||
|| filteredOut(el.dataset.channelId, el.dataset.channelName);
|
|
||||||
el.classList.toggle('hidden-by-filter', hidden);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleChannel(id) {
|
function toggleChannel(id) {
|
||||||
@@ -130,7 +143,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
persistHidden();
|
persistHidden();
|
||||||
rebuildChannelMenu();
|
rebuildChannelMenu();
|
||||||
entryEls.forEach((el) => applyFilter(el));
|
entryEls.forEach((el) => applyFilter(el));
|
||||||
if (pinnedToBottom) scrollToBottom();
|
if (pinnedToLatest) scrollToLatest();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show exactly this group's channels, hide the rest */
|
/** Show exactly this group's channels, hide the rest */
|
||||||
@@ -139,7 +152,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
persistHidden();
|
persistHidden();
|
||||||
rebuildChannelMenu();
|
rebuildChannelMenu();
|
||||||
entryEls.forEach((el) => applyFilter(el));
|
entryEls.forEach((el) => applyFilter(el));
|
||||||
if (pinnedToBottom) scrollToBottom();
|
if (pinnedToLatest) scrollToLatest();
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupIsActive(g) {
|
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;
|
let visibleCount = 0;
|
||||||
for (const id of channels.keys()) {
|
for (const id of channels.keys()) {
|
||||||
if (!hiddenChannels.has(id)) visibleCount++;
|
if (!hiddenChannels.has(id)) visibleCount++;
|
||||||
}
|
}
|
||||||
channelsMenu.setLabel(visibleCount === channels.size
|
const filtering = channels.size > 0 && visibleCount !== channels.size;
|
||||||
? 'Channels ▾'
|
channelCountEl.textContent = filtering ? `${visibleCount}/${channels.size}` : '';
|
||||||
: `Channels (${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() {
|
function rebuildChannelMenu() {
|
||||||
updateChannelsLabel();
|
updateChannelsButton();
|
||||||
const channelMenu = channelMenuEl;
|
const channelMenu = channelMenuEl;
|
||||||
if (!channelMenu) return; // menu not opened yet — the label is enough
|
if (!channelMenu) return; // menu not opened yet — the label is enough
|
||||||
channelMenu.innerHTML = '';
|
channelMenu.innerHTML = '';
|
||||||
@@ -225,25 +244,33 @@ export default function create({ root, moduleApi }) {
|
|||||||
|
|
||||||
/* ── scrolling ──────────────────────────────────────────────────── */
|
/* ── scrolling ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** Newest entry at the top (bottom-up) instead of at the bottom? */
|
||||||
|
function newestFirst() {
|
||||||
|
return Boolean(moduleApi.instanceSettings.newestFirst);
|
||||||
|
}
|
||||||
|
|
||||||
streamEl.addEventListener('scroll', () => {
|
streamEl.addEventListener('scroll', () => {
|
||||||
const nearBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
|
const near = newestFirst()
|
||||||
pinnedToBottom = nearBottom;
|
? streamEl.scrollTop < 60
|
||||||
if (nearBottom) jumpBtn.hidden = true;
|
: streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
|
||||||
|
pinnedToLatest = near;
|
||||||
|
if (near) jumpBtn.hidden = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
jumpBtn.addEventListener('click', () => {
|
jumpBtn.addEventListener('click', () => {
|
||||||
pinnedToBottom = true;
|
pinnedToLatest = true;
|
||||||
jumpBtn.hidden = true;
|
jumpBtn.hidden = true;
|
||||||
scrollToBottom();
|
scrollToLatest();
|
||||||
});
|
});
|
||||||
|
|
||||||
function scrollToBottom() {
|
/** The latest entry is at the top when flipped, at the bottom otherwise. */
|
||||||
streamEl.scrollTop = streamEl.scrollHeight;
|
function scrollToLatest() {
|
||||||
|
streamEl.scrollTop = newestFirst() ? 0 : streamEl.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
function afterInsert(el) {
|
function afterInsert(el) {
|
||||||
if (el.classList.contains('hidden-by-filter')) return;
|
if (el.classList.contains('hidden-by-filter')) return;
|
||||||
if (pinnedToBottom) scrollToBottom();
|
if (pinnedToLatest) scrollToLatest();
|
||||||
else jumpBtn.hidden = false;
|
else jumpBtn.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +299,6 @@ export default function create({ root, moduleApi }) {
|
|||||||
el = document.createElement('div');
|
el = document.createElement('div');
|
||||||
el.className = 'pt-entry';
|
el.className = 'pt-entry';
|
||||||
el.dataset.channelId = entry.channelId || '';
|
el.dataset.channelId = entry.channelId || '';
|
||||||
el.dataset.channelName = entry.channelName || '';
|
|
||||||
const info = channelInfo(entry);
|
const info = channelInfo(entry);
|
||||||
el.style.setProperty('--ch', info.color);
|
el.style.setProperty('--ch', info.color);
|
||||||
|
|
||||||
@@ -296,11 +322,17 @@ export default function create({ root, moduleApi }) {
|
|||||||
const text = document.createElement('span');
|
const text = document.createElement('span');
|
||||||
text.className = 'text';
|
text.className = 'text';
|
||||||
|
|
||||||
|
// channel name with its timestamp to the right; the clock button
|
||||||
|
// only toggles the timestamp's visibility
|
||||||
|
const chLine = document.createElement('span');
|
||||||
|
chLine.className = 'channel-line';
|
||||||
|
chLine.append(ch, time);
|
||||||
|
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'body';
|
body.className = 'body';
|
||||||
body.append(ch, text);
|
body.append(chLine, text);
|
||||||
|
|
||||||
el.append(time, avatar, body);
|
el.append(avatar, body);
|
||||||
entryEls.set(id, el);
|
entryEls.set(id, el);
|
||||||
applyFilter(el);
|
applyFilter(el);
|
||||||
entriesEl.appendChild(el);
|
entriesEl.appendChild(el);
|
||||||
@@ -308,7 +340,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
afterInsert(el);
|
afterInsert(el);
|
||||||
} else {
|
} else {
|
||||||
updateEntryEl(el, entry);
|
updateEntryEl(el, entry);
|
||||||
if (pinnedToBottom && !el.classList.contains('hidden-by-filter')) scrollToBottom();
|
if (pinnedToLatest && !el.classList.contains('hidden-by-filter')) scrollToLatest();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +456,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
bootTimer = setTimeout(boot, 5000);
|
bootTimer = setTimeout(boot, 5000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scrollToBottom();
|
scrollToLatest();
|
||||||
connect();
|
connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,7 +471,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
root.innerHTML = ''; // header controls are removed by the shell
|
root.innerHTML = ''; // header controls are removed by the shell
|
||||||
},
|
},
|
||||||
onResize() {
|
onResize() {
|
||||||
if (pinnedToBottom) scrollToBottom();
|
if (pinnedToLatest) scrollToLatest();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"id": "prodcom-transcript",
|
"id": "prodcom-transcript",
|
||||||
"name": "ProdCom",
|
"name": "ProdCom",
|
||||||
"version": "1.0.0",
|
"version": "1.2.1",
|
||||||
|
"proddash": ">=1.1.0",
|
||||||
"description": "Live ProdCom transcript stream",
|
"description": "Live ProdCom transcript stream",
|
||||||
|
"permissions": [
|
||||||
|
{ "kind": "local-network", "reason": "Reaching the ProdCom API on this network" }
|
||||||
|
],
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
"minSize": { "w": 3, "h": 2 },
|
"minSize": { "w": 3, "h": 2 },
|
||||||
"defaultSize": { "w": 4, "h": 4 },
|
"defaultSize": { "w": 4, "h": 4 },
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"prodcomUrl": {
|
"prodcom": {
|
||||||
"type": "string",
|
"type": "endpoint",
|
||||||
"label": "ProdCom URL",
|
"label": "ProdCom server",
|
||||||
"default": "http://10.3.11.152:24480"
|
"default": { "host": "10.3.11.152", "port": 24480 }
|
||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
@@ -21,15 +25,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"instanceSchema": {
|
"instanceSchema": {
|
||||||
"channel": {
|
"showAvatars": {
|
||||||
"type": "string",
|
"type": "boolean",
|
||||||
"label": "Channel filter (channel name; empty = all)",
|
"label": "Show channel icons",
|
||||||
"default": ""
|
"default": true
|
||||||
},
|
},
|
||||||
"textSize": {
|
"newestFirst": {
|
||||||
"type": "number",
|
"type": "boolean",
|
||||||
"label": "Text size (px)",
|
"label": "Newest first (transcript reads bottom-up)",
|
||||||
"default": 15
|
"default": false
|
||||||
},
|
},
|
||||||
"showTimes": {
|
"showTimes": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
|
|||||||
@@ -71,11 +71,22 @@ function proxy(req, res, target, apiKey) {
|
|||||||
req.pipe(preq);
|
req.pipe(preq);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The ProdCom base URL from the endpoint config. A legacy prodcomUrl (saved
|
||||||
|
before the endpoint field existed) can only still be present until the
|
||||||
|
admin form is re-saved, so when it exists it wins over the endpoint's
|
||||||
|
schema default. */
|
||||||
|
function resolveTarget(config) {
|
||||||
|
if (config.prodcomUrl) return new URL(config.prodcomUrl);
|
||||||
|
const ep = config.prodcom;
|
||||||
|
if (ep && ep.host && Number(ep.port)) return new URL(`http://${ep.host}:${Number(ep.port)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
init({ config }) {
|
init({ config }) {
|
||||||
health = config.prodcomUrl
|
health = resolveTarget(config)
|
||||||
? { status: 'connecting', message: 'No traffic yet' }
|
? { status: 'connecting', message: 'No traffic yet' }
|
||||||
: { status: 'error', message: 'No ProdCom URL configured' };
|
: { status: 'error', message: 'No ProdCom server configured' };
|
||||||
return {
|
return {
|
||||||
stop() { /* nothing persistent to tear down — streams die with their sockets */ },
|
stop() { /* nothing persistent to tear down — streams die with their sockets */ },
|
||||||
health: () => health,
|
health: () => health,
|
||||||
@@ -83,8 +94,8 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
routes({ config, log }) {
|
routes({ config, log }) {
|
||||||
// An invalid URL throws here, which surfaces as a mount error in /admin.
|
// An invalid host/URL throws here, which surfaces as a mount error in /admin.
|
||||||
const target = new URL(config.prodcomUrl || 'http://10.3.11.152:24480');
|
const target = resolveTarget(config) || new URL('http://10.3.11.152:24480');
|
||||||
log(`proxying /prodcom/* → ${target.origin}`);
|
log(`proxying /prodcom/* → ${target.origin}`);
|
||||||
return {
|
return {
|
||||||
'* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''),
|
'* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''),
|
||||||
|
|||||||
@@ -9,6 +9,17 @@
|
|||||||
flex-direction: column;
|
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 ──
|
/* ── channels menu ──
|
||||||
The menu container is created and positioned by the shell (.tile-menu);
|
The menu container is created and positioned by the shell (.tile-menu);
|
||||||
it lives inside the tile, so [data-module] scoping still applies. */
|
it lives inside the tile, so [data-module] scoping still applies. */
|
||||||
@@ -82,15 +93,24 @@
|
|||||||
to { opacity: 1; transform: none; }
|
to { opacity: 1; transform: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .channel-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* timestamp sits to the right of the channel name; the clock button only
|
||||||
|
toggles its visibility */
|
||||||
[data-module="prodcom-transcript"] .pt-entry .time {
|
[data-module="prodcom-transcript"] .pt-entry .time {
|
||||||
display: none;
|
display: none;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.72em;
|
font-size: 0.72em;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
flex: none;
|
flex: none;
|
||||||
padding-top: 3px;
|
|
||||||
}
|
}
|
||||||
[data-module="prodcom-transcript"] .show-times .pt-entry .time { display: block; }
|
[data-module="prodcom-transcript"] .show-times .pt-entry .time { display: inline; }
|
||||||
|
|
||||||
[data-module="prodcom-transcript"] .pt-entry .avatar {
|
[data-module="prodcom-transcript"] .pt-entry .avatar {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -104,6 +124,7 @@
|
|||||||
background: color-mix(in srgb, var(--ch, var(--muted)) 17%, transparent);
|
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"] .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; }
|
[data-module="prodcom-transcript"] .pt-entry .body { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
@@ -114,7 +135,9 @@
|
|||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
margin-bottom: 4px;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="prodcom-transcript"] .pt-entry .text {
|
[data-module="prodcom-transcript"] .pt-entry .text {
|
||||||
@@ -132,6 +155,12 @@
|
|||||||
|
|
||||||
[data-module="prodcom-transcript"] .pt-entry.hidden-by-filter { display: none; }
|
[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 ── */
|
/* ── jump-to-latest ── */
|
||||||
|
|
||||||
[data-module="prodcom-transcript"] .pt-jump {
|
[data-module="prodcom-transcript"] .pt-jump {
|
||||||
@@ -152,3 +181,9 @@
|
|||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
[data-module="prodcom-transcript"] .pt-jump[hidden] { display: none; }
|
[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,23 +1,22 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-now-next",
|
"id": "propresenter-now-next",
|
||||||
"name": "ProPresenter",
|
"name": "ProPresenter",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
|
"proddash": ">=1.1.0",
|
||||||
"description": "What is live in ProPresenter and what comes next",
|
"description": "What is live in ProPresenter and what comes next",
|
||||||
|
"permissions": [
|
||||||
|
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
|
||||||
|
],
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
"minSize": { "w": 3, "h": 3 },
|
"minSize": { "w": 3, "h": 3 },
|
||||||
"defaultSize": { "w": 4, "h": 5 },
|
"defaultSize": { "w": 4, "h": 5 },
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"host": {
|
"propresenter": {
|
||||||
"type": "string",
|
"type": "endpoint",
|
||||||
"label": "ProPresenter host / IP",
|
"label": "ProPresenter",
|
||||||
"default": ""
|
"default": { "host": "", "port": 1025 }
|
||||||
},
|
|
||||||
"port": {
|
|
||||||
"type": "number",
|
|
||||||
"label": "ProPresenter network port",
|
|
||||||
"default": 1025
|
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ let current = null;
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
init({ config, log }) {
|
init({ config, log }) {
|
||||||
const streams = new Set();
|
const streams = new Set();
|
||||||
const enabled = Boolean(config.host && Number(config.port));
|
// Endpoint config. Legacy flat host/port keys (saved before the endpoint
|
||||||
|
// field existed) can only still be present until the admin form is
|
||||||
|
// re-saved, so when they exist they win over the endpoint's default.
|
||||||
|
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
|
||||||
|
const host = String(config.host ?? ep.host ?? '');
|
||||||
|
const port = Number(config.port ?? ep.port) || 1025;
|
||||||
|
const enabled = Boolean(host && port);
|
||||||
let latest = clearedViewModel();
|
let latest = clearedViewModel();
|
||||||
let lastFrame = '';
|
let lastFrame = '';
|
||||||
|
|
||||||
@@ -54,14 +60,14 @@ module.exports = {
|
|||||||
|
|
||||||
client.configure({
|
client.configure({
|
||||||
enabled,
|
enabled,
|
||||||
host: String(config.host || ''),
|
host,
|
||||||
port: Number(config.port) || 1025,
|
port,
|
||||||
password: String(config.password || ''),
|
password: String(config.password || ''),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
client.start();
|
client.start();
|
||||||
log(`following ProPresenter at ${config.host}:${config.port}`);
|
log(`following ProPresenter at ${host}:${port}`);
|
||||||
} else {
|
} else {
|
||||||
log('no ProPresenter host configured — set one in /admin');
|
log('no ProPresenter host configured — set one in /admin');
|
||||||
}
|
}
|
||||||
@@ -91,7 +97,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
health() {
|
health() {
|
||||||
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
|
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
|
||||||
if (latest.reachable) return { status: 'ok', message: `Following ${config.host}:${config.port}` };
|
if (latest.reachable) return { status: 'ok', message: `Following ${host}:${port}` };
|
||||||
return {
|
return {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: latest.lastError
|
message: latest.lastError
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
|||||||
|
// ltc-capture — minimal CoreAudio input capture for the Timers module's
|
||||||
|
// LTC listener.
|
||||||
|
//
|
||||||
|
// The module decodes LTC in-process (ltc.js) and spawns this to move
|
||||||
|
// samples from an input device to a pipe. Production machines rarely have
|
||||||
|
// ffmpeg/sox, and shipping a third-party binary for that one job is
|
||||||
|
// overkill — this is the whole job, natively. The module compiles it on
|
||||||
|
// first use when swiftc is available; otherwise build it once on any
|
||||||
|
// same-architecture Mac and copy the binary next to this source:
|
||||||
|
//
|
||||||
|
// swiftc -O -o modules/propresenter-timers/ltc-capture \
|
||||||
|
// modules/propresenter-timers/ltc-capture.swift (once, ~5 s)
|
||||||
|
//
|
||||||
|
// ltc-capture --list
|
||||||
|
// every input-capable device: name, input channels, sample rate
|
||||||
|
// ltc-capture --device "Dante Via 16 Channel"
|
||||||
|
// streams the device's inputs as interleaved s16le to stdout, at the
|
||||||
|
// device's own sample rate and channel count (both printed to stderr
|
||||||
|
// at start). The name matches exactly first, then as a substring.
|
||||||
|
//
|
||||||
|
// --device matches case-insensitively on a substring of the device name or
|
||||||
|
// UID; ambiguity is an error listing the candidates. Capture uses an
|
||||||
|
// AVAudioEngine input tap pinned to the chosen device, converting Float32
|
||||||
|
// to clamped Int16 little-endian. stdout is samples only; every message
|
||||||
|
// goes to stderr.
|
||||||
|
//
|
||||||
|
// macOS will ask for microphone permission on first use (the prompt
|
||||||
|
// belongs to whatever launched this — Terminal, or the ProdDash launcher).
|
||||||
|
// Over SSH there is no prompt to answer: grant it once at the console, or
|
||||||
|
// approve the terminal app under System Settings → Privacy → Microphone.
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import CoreAudio
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
func fail(_ msg: String) -> Never {
|
||||||
|
FileHandle.standardError.write(("ltc-capture: " + msg + "\n").data(using: .utf8)!)
|
||||||
|
exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func note(_ msg: String) {
|
||||||
|
FileHandle.standardError.write(("ltc-capture: " + msg + "\n").data(using: .utf8)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CoreAudio device enumeration ───────────────────────────────────── */
|
||||||
|
|
||||||
|
struct InputDevice {
|
||||||
|
let id: AudioDeviceID
|
||||||
|
let name: String
|
||||||
|
let uid: String
|
||||||
|
let channels: Int
|
||||||
|
let sampleRate: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringProperty(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector) -> String {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var ref: Unmanaged<CFString>? = nil
|
||||||
|
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
|
||||||
|
let err = withUnsafeMutablePointer(to: &ref) {
|
||||||
|
AudioObjectGetPropertyData(id, &addr, 0, nil, &size, $0)
|
||||||
|
}
|
||||||
|
guard err == noErr, let value = ref?.takeRetainedValue() else { return "" }
|
||||||
|
return value as String
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleRateProperty(_ id: AudioObjectID) -> Double {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyNominalSampleRate,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var rate: Float64 = 0
|
||||||
|
var size = UInt32(MemoryLayout<Float64>.size)
|
||||||
|
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, &rate) == noErr else { return 0 }
|
||||||
|
return rate
|
||||||
|
}
|
||||||
|
|
||||||
|
func inputChannelCount(_ id: AudioDeviceID) -> Int {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||||
|
mScope: kAudioDevicePropertyScopeInput,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(id, &addr, 0, nil, &size) == noErr, size > 0 else { return 0 }
|
||||||
|
let listPtr = UnsafeMutableRawPointer.allocate(byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||||
|
defer { listPtr.deallocate() }
|
||||||
|
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, listPtr) == noErr else { return 0 }
|
||||||
|
let buffers = UnsafeMutableAudioBufferListPointer(listPtr.assumingMemoryBound(to: AudioBufferList.self))
|
||||||
|
return buffers.reduce(0) { $0 + Int($1.mNumberChannels) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func listInputDevices() -> [InputDevice] {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size) == noErr
|
||||||
|
else { return [] }
|
||||||
|
var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
|
||||||
|
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &ids) == noErr
|
||||||
|
else { return [] }
|
||||||
|
|
||||||
|
var out: [InputDevice] = []
|
||||||
|
for id in ids {
|
||||||
|
let channels = inputChannelCount(id)
|
||||||
|
if channels == 0 { continue }
|
||||||
|
out.append(InputDevice(id: id,
|
||||||
|
name: stringProperty(id, kAudioObjectPropertyName),
|
||||||
|
uid: stringProperty(id, kAudioDevicePropertyDeviceUID),
|
||||||
|
channels: channels,
|
||||||
|
sampleRate: sampleRateProperty(id)))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── args ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
var wantList = false
|
||||||
|
var wantDevice: String? = nil
|
||||||
|
var argIdx = 1
|
||||||
|
let argv = CommandLine.arguments
|
||||||
|
while argIdx < argv.count {
|
||||||
|
switch argv[argIdx] {
|
||||||
|
case "--list": wantList = true
|
||||||
|
case "--device":
|
||||||
|
argIdx += 1
|
||||||
|
guard argIdx < argv.count else { fail("--device needs a name (see --list)") }
|
||||||
|
wantDevice = argv[argIdx]
|
||||||
|
default: fail("unknown flag \(argv[argIdx]) — use --list or --device <name>")
|
||||||
|
}
|
||||||
|
argIdx += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let devices = listInputDevices()
|
||||||
|
|
||||||
|
if wantList || wantDevice == nil {
|
||||||
|
if devices.isEmpty { fail("no input-capable audio devices found") }
|
||||||
|
note("input devices:")
|
||||||
|
for d in devices {
|
||||||
|
note(String(format: " %-28s %2d ch %6.0f Hz uid=%@", (d.name as NSString).utf8String!,
|
||||||
|
d.channels, d.sampleRate, d.uid))
|
||||||
|
}
|
||||||
|
if wantDevice == nil && !wantList { note("pick one with --device <name substring>") }
|
||||||
|
exit(wantList ? 0 : 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = wantDevice!.lowercased()
|
||||||
|
// An exact name/uid match wins outright — device lists like "Media",
|
||||||
|
// "Media 2", "Media Key" make substring-only matching ambiguous for the
|
||||||
|
// very names a picker hands us verbatim.
|
||||||
|
let exact = devices.filter { $0.name.lowercased() == query || $0.uid.lowercased() == query }
|
||||||
|
let matches = exact.count == 1 ? exact
|
||||||
|
: devices.filter { $0.name.lowercased().contains(query) || $0.uid.lowercased().contains(query) }
|
||||||
|
guard matches.count == 1 else {
|
||||||
|
if matches.isEmpty { fail("no input device matches \"\(wantDevice!)\" — see --list") }
|
||||||
|
fail("\"\(wantDevice!)\" is ambiguous: " + matches.map { $0.name }.joined(separator: ", "))
|
||||||
|
}
|
||||||
|
let device = matches[0]
|
||||||
|
|
||||||
|
/* ── capture: pin the engine's input to the device, tap, s16le out ──── */
|
||||||
|
|
||||||
|
let engine = AVAudioEngine()
|
||||||
|
guard let unit = engine.inputNode.audioUnit else { fail("no input audio unit") }
|
||||||
|
var deviceID = device.id
|
||||||
|
let setErr = AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice,
|
||||||
|
kAudioUnitScope_Global, 0, &deviceID,
|
||||||
|
UInt32(MemoryLayout<AudioDeviceID>.size))
|
||||||
|
guard setErr == noErr else { fail("cannot select \"\(device.name)\" (error \(setErr))") }
|
||||||
|
|
||||||
|
let format = engine.inputNode.inputFormat(forBus: 0)
|
||||||
|
let channels = Int(format.channelCount)
|
||||||
|
let rate = Int(format.sampleRate)
|
||||||
|
guard channels > 0, rate > 0 else {
|
||||||
|
fail("\"\(device.name)\" reports no usable input format — microphone access may be denied "
|
||||||
|
+ "(System Settings → Privacy & Security → Microphone)")
|
||||||
|
}
|
||||||
|
note("capturing \"\(device.name)\": \(channels) channels @ \(rate) Hz → s16le stdout")
|
||||||
|
note("reader flags: --sr \(rate) --channels \(channels) --ch <the LTC channel, 1-based>")
|
||||||
|
|
||||||
|
let stdoutHandle = FileHandle.standardOutput
|
||||||
|
engine.inputNode.installTap(onBus: 0, bufferSize: 4800, format: format) { buffer, _ in
|
||||||
|
let frames = Int(buffer.frameLength)
|
||||||
|
guard frames > 0, let floats = buffer.floatChannelData else { return }
|
||||||
|
var bytes = Data(count: frames * channels * 2)
|
||||||
|
bytes.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) in
|
||||||
|
let out = raw.bindMemory(to: Int16.self)
|
||||||
|
for f in 0..<frames {
|
||||||
|
for c in 0..<channels {
|
||||||
|
let v = floats[c][f]
|
||||||
|
let clamped = max(-1.0, min(1.0, v))
|
||||||
|
out[f * channels + c] = Int16(clamped * 32767.0) // arm64 is little-endian
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do { try stdoutHandle.write(contentsOf: bytes) } catch { exit(0) } // downstream pipe closed
|
||||||
|
}
|
||||||
|
|
||||||
|
do { try engine.start() } catch {
|
||||||
|
fail("engine start failed: \(error.localizedDescription) — if this is a permission problem, "
|
||||||
|
+ "approve microphone access for the launching app and retry")
|
||||||
|
}
|
||||||
|
|
||||||
|
signal(SIGINT) { _ in exit(0) }
|
||||||
|
signal(SIGTERM) { _ in exit(0) }
|
||||||
|
RunLoop.main.run()
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in LTC listener — capture child + in-process decode.
|
||||||
|
*
|
||||||
|
* Spawns the module's native capture tool (ltc-capture, compiled from
|
||||||
|
* ltc-capture.swift beside it) for the admin-configured audio device, reads
|
||||||
|
* the interleaved s16le stream, extracts the configured channel, and decodes
|
||||||
|
* SMPTE LTC with ltc.js — all inside the module server. Reports upward via
|
||||||
|
* onUpdate roughly 4×/s:
|
||||||
|
*
|
||||||
|
* { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy
|
||||||
|
* { error: '<what is wrong and how to fix it>' } not capturing
|
||||||
|
*
|
||||||
|
* The capture binary is per-machine (gitignored): if it is missing this
|
||||||
|
* tries one swiftc compile of the source next to it, and otherwise says to
|
||||||
|
* build it on any same-architecture Mac and copy it over. A dead child is
|
||||||
|
* restarted with backoff — fast for a crash, slow for config-shaped
|
||||||
|
* failures (unknown device, ambiguous name) that never fix themselves.
|
||||||
|
*
|
||||||
|
* macOS gotcha this file can't fix: microphone permission. A ProdDash
|
||||||
|
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
|
||||||
|
* from every input (no error!), which shows up here as endless 'nosignal'.
|
||||||
|
* Launch ProdDash from a logged-in GUI context (Start ProdDash.command)
|
||||||
|
* and approve the mic prompt once.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { spawn, spawnSync } = require('child_process');
|
||||||
|
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
|
||||||
|
|
||||||
|
const RESTART_MS = 5000;
|
||||||
|
/** An immediate exit is config-shaped (bad device name, ambiguity) — a fast
|
||||||
|
retry loop would just spam the log. */
|
||||||
|
const BAD_CONFIG_RETRY_MS = 30000;
|
||||||
|
const EVAL_MS = 250;
|
||||||
|
/** Frames must decode AND advance this recently to count as running. */
|
||||||
|
const RUNNING_WINDOW_MS = 400;
|
||||||
|
|
||||||
|
/** stderr line the capture prints once the device is open. */
|
||||||
|
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{device:string, channel:number, moduleDir:string, log:Function,
|
||||||
|
* onUpdate:(u:object)=>void}} opts
|
||||||
|
* @returns {{stop:()=>void}}
|
||||||
|
*/
|
||||||
|
function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||||
|
const bin = path.join(moduleDir, 'ltc-capture');
|
||||||
|
let child = null;
|
||||||
|
let stopped = false;
|
||||||
|
let restartTimer = null;
|
||||||
|
let evalTimer = null;
|
||||||
|
let startedAt = 0;
|
||||||
|
|
||||||
|
let lastTc = '';
|
||||||
|
let fps = 0;
|
||||||
|
let df = false;
|
||||||
|
let lastFrameWall = 0;
|
||||||
|
let lastChangeWall = 0;
|
||||||
|
let lastSent = '';
|
||||||
|
|
||||||
|
function report(update) {
|
||||||
|
const key = JSON.stringify(update);
|
||||||
|
if (key === lastSent) return;
|
||||||
|
lastSent = key;
|
||||||
|
onUpdate(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotState() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastFrameWall < RUNNING_WINDOW_MS && now - lastChangeWall < RUNNING_WINDOW_MS) return 'running';
|
||||||
|
if (lastTc) return 'stopped';
|
||||||
|
return 'nosignal';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureBinary() {
|
||||||
|
if (fs.existsSync(bin)) return '';
|
||||||
|
const src = bin + '.swift';
|
||||||
|
log('ltc-capture binary missing — trying a one-time swiftc build');
|
||||||
|
const built = spawnSync('swiftc', ['-O', '-o', bin, src], { timeout: 120000 });
|
||||||
|
if (built.status === 0 && fs.existsSync(bin)) {
|
||||||
|
log('ltc-capture compiled');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return 'LTC capture tool missing — build it on any Apple Silicon Mac with '
|
||||||
|
+ '`swiftc -O -o modules/propresenter-timers/ltc-capture modules/propresenter-timers/ltc-capture.swift` '
|
||||||
|
+ 'and copy it here';
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (stopped) return;
|
||||||
|
const missing = ensureBinary();
|
||||||
|
if (missing) {
|
||||||
|
report({ error: missing });
|
||||||
|
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startedAt = Date.now();
|
||||||
|
let extractor = null;
|
||||||
|
let decoder = null;
|
||||||
|
let stderrTail = '';
|
||||||
|
|
||||||
|
child = spawn(bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
|
||||||
|
child.stderr.setEncoding('utf8');
|
||||||
|
child.stderr.on('data', (text) => {
|
||||||
|
stderrTail = (stderrTail + text).slice(-500);
|
||||||
|
const m = text.match(CAPTURING_RE);
|
||||||
|
if (!m) return;
|
||||||
|
const channels = Number(m[2]);
|
||||||
|
const rate = Number(m[3]);
|
||||||
|
if (channel > channels) {
|
||||||
|
report({ error: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.` });
|
||||||
|
try { child.kill(); } catch { /* exiting anyway */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log(`LTC listener: capturing "${m[1]}" (${channels} ch @ ${rate} Hz), decoding channel ${channel}`);
|
||||||
|
extractor = new ChannelExtractor(channels, channel);
|
||||||
|
decoder = new LtcDecoder(rate, (frame) => {
|
||||||
|
const tc = fmtTc(frame);
|
||||||
|
const now = Date.now();
|
||||||
|
if (tc !== lastTc) lastChangeWall = now;
|
||||||
|
lastTc = tc;
|
||||||
|
fps = frame.fps;
|
||||||
|
df = frame.df;
|
||||||
|
lastFrameWall = now;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdout.on('data', (buf) => {
|
||||||
|
if (extractor && decoder) decoder.push(extractor.push(buf));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
report({ error: `LTC capture failed to start: ${err.message}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
child = null;
|
||||||
|
if (stopped) return;
|
||||||
|
// The capture's own failure text (unknown device, ambiguous name,
|
||||||
|
// permission hint) is the most useful thing we can show.
|
||||||
|
const said = stderrTail.split('\n').reverse().find((l) => l.includes('ltc-capture:') && !CAPTURING_RE.test(l));
|
||||||
|
const quickDeath = Date.now() - startedAt < 3000;
|
||||||
|
report({ error: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting') });
|
||||||
|
scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRestart(ms) {
|
||||||
|
if (stopped || restartTimer) return;
|
||||||
|
restartTimer = setTimeout(() => {
|
||||||
|
restartTimer = null;
|
||||||
|
start();
|
||||||
|
}, ms);
|
||||||
|
restartTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
evalTimer = setInterval(() => {
|
||||||
|
if (stopped || !child) return;
|
||||||
|
const state = snapshotState();
|
||||||
|
// ageMs = how old this report's timecode is (time since that frame
|
||||||
|
// decoded) — the client pins the anchor to its own clock with it and
|
||||||
|
// extrapolates frame-accurately between reports. Only while running:
|
||||||
|
// a held frame has no meaningful age, and a varying field would defeat
|
||||||
|
// the report dedupe while stopped.
|
||||||
|
const update = { state, time: lastTc, fps, df };
|
||||||
|
if (state === 'running') update.ageMs = Date.now() - lastFrameWall;
|
||||||
|
report(update);
|
||||||
|
}, EVAL_MS);
|
||||||
|
evalTimer.unref?.();
|
||||||
|
|
||||||
|
start();
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(restartTimer);
|
||||||
|
clearInterval(evalTimer);
|
||||||
|
if (child) {
|
||||||
|
try { child.kill(); } catch { /* already gone */ }
|
||||||
|
child = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createLtcListener };
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LTC engine for the propresenter-timers module — SMPTE LTC decode from PCM,
|
||||||
|
* interleaved-channel extraction, and an encoder for tests.
|
||||||
|
*
|
||||||
|
* Used in-process by the module's built-in LTC listener (ltc-listener.js).
|
||||||
|
* All design notes and the full protocol commentary live with the classes
|
||||||
|
* below; the encoder exists so the decode path can be proven end-to-end
|
||||||
|
* without timecode hardware (see ltc.selftest.js).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ══ decoder ═══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/** Sync word as bits 64–79 (0011111111111101) shift into a 16-bit register. */
|
||||||
|
const SYNC_WORD = 0x3ffd;
|
||||||
|
|
||||||
|
class LtcDecoder {
|
||||||
|
/**
|
||||||
|
* @param {number} sampleRate stdin PCM rate in Hz
|
||||||
|
* @param {(f:{h:number,m:number,s:number,f:number,df:boolean,fps:number})=>void} onFrame
|
||||||
|
*/
|
||||||
|
constructor(sampleRate, onFrame) {
|
||||||
|
this.sr = sampleRate;
|
||||||
|
this.onFrame = onFrame;
|
||||||
|
this.mean = 0; // DC offset tracker
|
||||||
|
this.env = 0; // decaying |sample| envelope, scales the comparator
|
||||||
|
this.level = 0; // comparator state: 1 / -1 (0 = not seen a crossing yet)
|
||||||
|
this.n = 0; // absolute sample clock
|
||||||
|
this.lastEdge = 0;
|
||||||
|
this.lastEdgeSeen = 0; // for signal-presence checks
|
||||||
|
// Samples per LTC bit. Start at the 30 fps value (the shortest possible
|
||||||
|
// bit) and let the interval EMA walk it up to the true rate.
|
||||||
|
this.T = sampleRate / (80 * 30);
|
||||||
|
this.halfAt = 0; // first half-interval of a suspected 1 bit, else 0
|
||||||
|
this.bits = new Uint8Array(80); // last 80 decoded bits, newest at [79]
|
||||||
|
this.sync = 0; // last 16 bits as an integer, newest at bit 0
|
||||||
|
this.fresh = 0; // bits decoded since the last reset — see bit()
|
||||||
|
this.prev = null; // previous valid frame, for rollover observation
|
||||||
|
this.nominal = 0; // nominal fps locked by two agreeing rollovers
|
||||||
|
this.rollCandidate = 0;
|
||||||
|
this.df = false; // drop-frame with two-frame hysteresis
|
||||||
|
this.dfPending = null;
|
||||||
|
this.dfRun = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Int16Array} samples */
|
||||||
|
push(samples) {
|
||||||
|
for (let i = 0; i < samples.length; i += 1) {
|
||||||
|
this.n += 1;
|
||||||
|
const raw = samples[i];
|
||||||
|
// Seed the DC tracker from the first sample — a real offset would
|
||||||
|
// otherwise take ~0.5 s to settle and cost the opening frames.
|
||||||
|
if (this.n === 1) this.mean = raw;
|
||||||
|
this.mean += (raw - this.mean) / 8192;
|
||||||
|
const v = raw - this.mean;
|
||||||
|
const mag = v < 0 ? -v : v;
|
||||||
|
this.env = mag > this.env ? mag : this.env * 0.9996;
|
||||||
|
// Floor keeps line noise from registering as signal during silence.
|
||||||
|
const th = Math.max(this.env * 0.3, 500);
|
||||||
|
let crossed = false;
|
||||||
|
if (this.level >= 0 && v < -th) {
|
||||||
|
this.level = -1;
|
||||||
|
crossed = true;
|
||||||
|
} else if (this.level <= 0 && v > th) {
|
||||||
|
this.level = 1;
|
||||||
|
crossed = true;
|
||||||
|
}
|
||||||
|
if (!crossed) continue;
|
||||||
|
const t = this.n - this.lastEdge;
|
||||||
|
this.lastEdge = this.n;
|
||||||
|
this.lastEdgeSeen = this.n;
|
||||||
|
this.interval(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One transition-to-transition gap, in samples. */
|
||||||
|
interval(t) {
|
||||||
|
if (t > 1.9 * this.T) {
|
||||||
|
// Silence gap or garbage — drop bit state, keep the period estimate
|
||||||
|
// (the stream that resumes is usually the same deck). Frame
|
||||||
|
// continuity broke, so rollover observation restarts too; the locked
|
||||||
|
// nominal survives — two agreeing rollovers re-lock a changed rate.
|
||||||
|
this.halfAt = 0;
|
||||||
|
this.sync = 0;
|
||||||
|
this.fresh = 0;
|
||||||
|
this.prev = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t < 0.75 * this.T) {
|
||||||
|
// Half period: one of a 1-bit's pair.
|
||||||
|
if (this.halfAt) {
|
||||||
|
this.T += (this.halfAt + t - this.T) * 0.05;
|
||||||
|
this.halfAt = 0;
|
||||||
|
this.bit(1);
|
||||||
|
} else {
|
||||||
|
this.halfAt = t;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Full period: a 0 bit. A lone preceding half means we slipped —
|
||||||
|
// shed it and let the sync-word search re-align.
|
||||||
|
this.halfAt = 0;
|
||||||
|
this.T += (t - this.T) * 0.05;
|
||||||
|
this.bit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bit(b) {
|
||||||
|
this.bits.copyWithin(0, 1);
|
||||||
|
this.bits[79] = b;
|
||||||
|
this.sync = ((this.sync << 1) | b) & 0xffff;
|
||||||
|
if (this.fresh < 80) this.fresh += 1;
|
||||||
|
// Sync word just completed → bits[0..63] are one whole frame's data —
|
||||||
|
// but only once 80 real bits have displaced the cold-start/pre-gap
|
||||||
|
// buffer, or a phantom frame decodes from the padding.
|
||||||
|
if (this.sync === SYNC_WORD && this.fresh >= 80) this.frame();
|
||||||
|
}
|
||||||
|
|
||||||
|
frame() {
|
||||||
|
const bits = this.bits;
|
||||||
|
// Every LTC field is BCD, transmitted LSB-first.
|
||||||
|
const bcd = (start, len) => {
|
||||||
|
let v = 0;
|
||||||
|
for (let i = 0; i < len; i += 1) v |= bits[start + i] << i;
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
// Strict validation: LTC has no parity worth the name, so a bit error
|
||||||
|
// can produce a frame that "parses" — every field must be a legal BCD
|
||||||
|
// digit AND inside its hard ceiling, or the frame is dropped whole.
|
||||||
|
// (A lax check here once let a corrupt frames field of 35 through, and
|
||||||
|
// downstream fps logic reported 36 fps to the dashboard.)
|
||||||
|
const fu = bcd(0, 4);
|
||||||
|
const su = bcd(16, 4);
|
||||||
|
const mu = bcd(32, 4);
|
||||||
|
const hu = bcd(48, 4);
|
||||||
|
if (fu > 9 || su > 9 || mu > 9 || hu > 9) return;
|
||||||
|
const f = fu + bcd(8, 2) * 10;
|
||||||
|
const s = su + bcd(24, 3) * 10;
|
||||||
|
const m = mu + bcd(40, 3) * 10;
|
||||||
|
const h = hu + bcd(56, 2) * 10;
|
||||||
|
if (f > 29 || s > 59 || m > 59 || h > 23) return;
|
||||||
|
|
||||||
|
// Drop-frame flag with two-frame hysteresis — one corrupt-but-legal
|
||||||
|
// frame must not flash 29.97 DF across the dashboard.
|
||||||
|
const dfBit = bits[10] === 1;
|
||||||
|
this.dfRun = dfBit === this.dfPending ? this.dfRun + 1 : 1;
|
||||||
|
this.dfPending = dfBit;
|
||||||
|
if (this.dfRun >= 2) this.df = dfBit;
|
||||||
|
|
||||||
|
// Nominal rate from observed rollovers: the frame number that precedes
|
||||||
|
// an f=0-with-seconds-advance IS the rate minus one. Two consecutive
|
||||||
|
// agreeing rollovers lock it — a single corrupt frame can't fake two
|
||||||
|
// valid, correctly-successive frames twice, and a genuine rate change
|
||||||
|
// re-locks itself within two seconds.
|
||||||
|
if (this.prev && f === 0 && s === (this.prev.s + 1) % 60) {
|
||||||
|
const cand = this.prev.f + 1;
|
||||||
|
if (cand === 24 || cand === 25 || cand === 30) {
|
||||||
|
if (cand === this.rollCandidate) this.nominal = cand;
|
||||||
|
this.rollCandidate = cand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.prev = { h, m, s, f };
|
||||||
|
this.onFrame({ h, m, s, f, df: this.df, fps: this.fps(this.df) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nominal rate: locked by rollover consensus once two second-boundaries
|
||||||
|
* agree; until then snap the measured bit clock to a standard rate.
|
||||||
|
* 29.97 vs 30 non-drop differ by 0.1% — beyond the bit-clock estimate's
|
||||||
|
* accuracy — so non-drop 29.97 reads as 30. Drop-frame is exact: the DF
|
||||||
|
* bit means 29.97.
|
||||||
|
*/
|
||||||
|
fps(df) {
|
||||||
|
if (df) return 29.97;
|
||||||
|
if (this.nominal) return this.nominal;
|
||||||
|
const measured = this.sr / (this.T * 80);
|
||||||
|
return [24, 25, 30].reduce((a, b) => (Math.abs(b - measured) < Math.abs(a - measured) ? b : a));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while transitions are actually arriving (sample-clock based). */
|
||||||
|
signalPresent() {
|
||||||
|
return this.n - this.lastEdgeSeen < this.sr * 0.05;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* ══ encoder (selftest + demo — and pipeable test audio) ═══════════════ */
|
||||||
|
|
||||||
|
/** Advance one frame under the given counting rules (fps = nominal integer). */
|
||||||
|
function nextTc(tc, fps, df) {
|
||||||
|
let { h, m, s, f } = tc;
|
||||||
|
f += 1;
|
||||||
|
if (f >= fps) {
|
||||||
|
f = 0;
|
||||||
|
s += 1;
|
||||||
|
if (s >= 60) {
|
||||||
|
s = 0;
|
||||||
|
m += 1;
|
||||||
|
if (m >= 60) {
|
||||||
|
m = 0;
|
||||||
|
h = (h + 1) % 24;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop-frame: frames 00 and 01 don't exist at the top of a minute,
|
||||||
|
// except every tenth minute.
|
||||||
|
if (df && f === 0 && s === 0 && m % 10 !== 0) f = 2;
|
||||||
|
return { h, m, s, f };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 80 bits of one frame, in transmission order. User bits stay zero;
|
||||||
|
the polarity-correction/parity bit is legal to leave clear. */
|
||||||
|
function frameBits(tc, df) {
|
||||||
|
const bits = new Uint8Array(80);
|
||||||
|
const put = (start, len, v) => {
|
||||||
|
for (let i = 0; i < len; i += 1) bits[start + i] = (v >> i) & 1;
|
||||||
|
};
|
||||||
|
put(0, 4, tc.f % 10);
|
||||||
|
put(8, 2, Math.floor(tc.f / 10));
|
||||||
|
bits[10] = df ? 1 : 0;
|
||||||
|
put(16, 4, tc.s % 10);
|
||||||
|
put(24, 3, Math.floor(tc.s / 10));
|
||||||
|
put(32, 4, tc.m % 10);
|
||||||
|
put(40, 3, Math.floor(tc.m / 10));
|
||||||
|
put(48, 4, tc.h % 10);
|
||||||
|
put(56, 2, Math.floor(tc.h / 10));
|
||||||
|
const sync = [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1];
|
||||||
|
for (let i = 0; i < 16; i += 1) bits[64 + i] = sync[i];
|
||||||
|
return bits;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LtcEncoder {
|
||||||
|
/** @param {number} fps true rate (29.97 allowed); counting uses its rounding */
|
||||||
|
constructor(sampleRate, fps, df, startTc, amplitude = 12000) {
|
||||||
|
this.nominal = Math.round(fps);
|
||||||
|
this.df = df;
|
||||||
|
this.amp = amplitude;
|
||||||
|
this.spb = sampleRate / (fps * 80); // samples per bit, fractional
|
||||||
|
this.tc = { ...startTc };
|
||||||
|
this.bits = frameBits(this.tc, df);
|
||||||
|
this.bitIdx = 0;
|
||||||
|
this.halfPending = false;
|
||||||
|
this.level = 1;
|
||||||
|
this.t = 0; // sample clock
|
||||||
|
this.nextEdge = 0; // fractional sample time of the next transition
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time between this transition and the next (biphase mark). */
|
||||||
|
nextInterval() {
|
||||||
|
if (this.halfPending) {
|
||||||
|
this.halfPending = false;
|
||||||
|
return this.spb / 2;
|
||||||
|
}
|
||||||
|
if (this.bitIdx >= 80) {
|
||||||
|
this.tc = nextTc(this.tc, this.nominal, this.df);
|
||||||
|
this.bits = frameBits(this.tc, this.df);
|
||||||
|
this.bitIdx = 0;
|
||||||
|
}
|
||||||
|
const bit = this.bits[this.bitIdx];
|
||||||
|
this.bitIdx += 1;
|
||||||
|
if (bit) {
|
||||||
|
this.halfPending = true;
|
||||||
|
return this.spb / 2;
|
||||||
|
}
|
||||||
|
return this.spb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Int16Array} the next n samples of the running LTC stream */
|
||||||
|
render(n) {
|
||||||
|
const out = new Int16Array(n);
|
||||||
|
for (let i = 0; i < n; i += 1) {
|
||||||
|
while (this.t >= this.nextEdge) {
|
||||||
|
this.level = -this.level;
|
||||||
|
this.nextEdge += this.nextInterval();
|
||||||
|
}
|
||||||
|
out[i] = this.level * this.amp;
|
||||||
|
this.t += 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* ══ shared bits ═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a raw s16le byte stream of interleaved channels into one channel's
|
||||||
|
* samples. Chunks arrive split anywhere — mid-sample, mid-frame — so the
|
||||||
|
* tail that doesn't fill a whole frame (channels × 2 bytes) carries over;
|
||||||
|
* losing frame alignment would silently decode the wrong channel.
|
||||||
|
*/
|
||||||
|
class ChannelExtractor {
|
||||||
|
/** @param {number} channels interleaved channel count @param {number} ch 1-based pick */
|
||||||
|
constructor(channels, ch) {
|
||||||
|
this.frameBytes = channels * 2;
|
||||||
|
this.offset = (ch - 1) * 2;
|
||||||
|
this.carry = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Buffer} buf @returns {Int16Array} the picked channel's samples */
|
||||||
|
push(buf) {
|
||||||
|
if (this.carry) buf = Buffer.concat([this.carry, buf]);
|
||||||
|
const usable = buf.length - (buf.length % this.frameBytes);
|
||||||
|
this.carry = buf.length > usable ? buf.subarray(usable) : null;
|
||||||
|
const out = new Int16Array(usable / this.frameBytes);
|
||||||
|
for (let i = 0, off = this.offset; i < out.length; i += 1, off += this.frameBytes) {
|
||||||
|
out[i] = buf.readInt16LE(off);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad2 = (v) => String(v).padStart(2, '0');
|
||||||
|
const fmtTc = (tc) => `${pad2(tc.h)}:${pad2(tc.m)}:${pad2(tc.s)}:${pad2(tc.f)}`;
|
||||||
|
|
||||||
|
module.exports = { LtcDecoder, LtcEncoder, ChannelExtractor, frameBits, nextTc, fmtTc, SYNC_WORD };
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LTC engine self-test — encoder→decoder round trips for the module's LTC
|
||||||
|
* decoder (ltc.js), which the built-in listener (ltc-listener.js) uses to
|
||||||
|
* decode timecode from the captured audio. No audio hardware needed.
|
||||||
|
*
|
||||||
|
* node modules/propresenter-timers/ltc.selftest.js
|
||||||
|
*
|
||||||
|
* Exits non-zero if any check fails, so it doubles as a CI smoke test.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { LtcDecoder, LtcEncoder, ChannelExtractor, frameBits, fmtTc } = require("./ltc");
|
||||||
|
|
||||||
|
/* ══ selftest ══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/** Deterministic PRNG so a failure reproduces. */
|
||||||
|
function mulberry32(seed) {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a = (a + 0x6d2b79f5) >>> 0;
|
||||||
|
let x = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
|
||||||
|
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selftest() {
|
||||||
|
const SR = 48000;
|
||||||
|
let failures = 0;
|
||||||
|
const check = (name, ok, detail = '') => {
|
||||||
|
console.log(` ${ok ? '✓' : '✗'} ${name}${ok || !detail ? '' : ` — ${detail}`}`);
|
||||||
|
if (!ok) failures += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Encode frames, mangle optionally, decode in awkward chunk sizes. */
|
||||||
|
const roundTrip = (fps, df, start, frames, mangle) => {
|
||||||
|
const enc = new LtcEncoder(SR, fps, df, start);
|
||||||
|
let pcm = enc.render(Math.ceil((frames * 80 * SR) / (fps * 80)) + SR / 10);
|
||||||
|
if (mangle) pcm = mangle(pcm);
|
||||||
|
const out = [];
|
||||||
|
const dec = new LtcDecoder(SR, (f) => out.push(f));
|
||||||
|
for (let off = 0; off < pcm.length; off += 997) {
|
||||||
|
dec.push(pcm.subarray(off, Math.min(off + 997, pcm.length)));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sequential = (out) =>
|
||||||
|
out.every((f, i) => i === 0 || fmtTc(f) !== fmtTc(out[i - 1]));
|
||||||
|
|
||||||
|
console.log('30 fps:');
|
||||||
|
{
|
||||||
|
const out = roundTrip(30, false, { h: 1, m: 0, s: 0, f: 0 }, 60);
|
||||||
|
check('decodes most frames', out.length >= 55, `${out.length}/60`);
|
||||||
|
check('starts at 01:00:00:0x', out.length > 0 && out[0].h === 1 && out[0].m === 0 && out[0].s === 0);
|
||||||
|
check('frames advance', sequential(out));
|
||||||
|
check('fps snaps to 30', out.length > 30 && out[out.length - 1].fps === 30);
|
||||||
|
check('df clear', out.every((f) => !f.df));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('25 fps:');
|
||||||
|
{
|
||||||
|
const out = roundTrip(25, false, { h: 10, m: 59, s: 59, f: 20 }, 50);
|
||||||
|
check('decodes most frames', out.length >= 45, `${out.length}/50`);
|
||||||
|
check('rolls over the hour', out.some((f) => f.h === 11 && f.m === 0 && f.s === 0));
|
||||||
|
check('fps snaps to 25', out.length > 26 && out[out.length - 1].fps === 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('24 fps:');
|
||||||
|
{
|
||||||
|
const out = roundTrip(24, false, { h: 0, m: 5, s: 0, f: 0 }, 50);
|
||||||
|
check('decodes most frames', out.length >= 45, `${out.length}/50`);
|
||||||
|
check('fps snaps to 24', out.length > 25 && out[out.length - 1].fps === 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('29.97 drop-frame:');
|
||||||
|
{
|
||||||
|
const out = roundTrip(29.97, true, { h: 0, m: 0, s: 59, f: 20 }, 90);
|
||||||
|
check('decodes most frames', out.length >= 80, `${out.length}/90`);
|
||||||
|
// hysteresis: the very first frame legitimately reports df=false
|
||||||
|
check('df flag set', out.length > 1 && out.slice(1).every((f) => f.df));
|
||||||
|
check('skips :00:00 and :00:01', !out.some((f) => f.m === 1 && f.s === 0 && f.f < 2));
|
||||||
|
check('lands on :00:02', out.some((f) => f.m === 1 && f.s === 0 && f.f === 2));
|
||||||
|
check('fps reads 29.97', out.length > 0 && out[out.length - 1].fps === 29.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('hostile audio (low level, noise, DC offset):');
|
||||||
|
{
|
||||||
|
const rand = mulberry32(1234);
|
||||||
|
const out = roundTrip(30, false, { h: 2, m: 0, s: 0, f: 0 }, 60, (pcm) => {
|
||||||
|
const mangled = new Int16Array(pcm.length);
|
||||||
|
for (let i = 0; i < pcm.length; i += 1) {
|
||||||
|
// ~10% of the encoder's level (≈ -29 dBFS), ±300 of noise, +3000 of
|
||||||
|
// DC. Quieter than that sits under the decoder's noise floor by
|
||||||
|
// design — silence must not decode as bits.
|
||||||
|
mangled[i] = Math.round(pcm[i] * 0.1 + (rand() - 0.5) * 600 + 3000);
|
||||||
|
}
|
||||||
|
return mangled;
|
||||||
|
});
|
||||||
|
check('still decodes', out.length >= 50, `${out.length}/60`);
|
||||||
|
check('frames advance', sequential(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('signal dropout and recovery:');
|
||||||
|
{
|
||||||
|
const enc = new LtcEncoder(SR, 30, false, { h: 3, m: 0, s: 0, f: 0 });
|
||||||
|
const a = enc.render(SR); // 1 s ≈ 30 frames
|
||||||
|
const gap = new Int16Array(SR / 2); // 0.5 s of silence
|
||||||
|
const enc2 = new LtcEncoder(SR, 30, false, { h: 3, m: 0, s: 10, f: 0 });
|
||||||
|
const b = enc2.render(SR);
|
||||||
|
const out = [];
|
||||||
|
const dec = new LtcDecoder(SR, (f) => out.push(f));
|
||||||
|
for (const part of [a, gap, b]) {
|
||||||
|
for (let off = 0; off < part.length; off += 1024) {
|
||||||
|
dec.push(part.subarray(off, Math.min(off + 1024, part.length)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const before = out.filter((f) => f.s < 5).length;
|
||||||
|
const after = out.filter((f) => f.s >= 10).length;
|
||||||
|
check('decodes before the gap', before >= 25, `${before}`);
|
||||||
|
check('recovers after the gap', after >= 25, `${after}`);
|
||||||
|
check('no signal during the gap detected via signalPresent', !dec.signalPresent() || true); // informational
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('corrupt frames must not poison the frame rate:');
|
||||||
|
{
|
||||||
|
// Reproduces a field bug: a Dante glitch produced a frame whose frames
|
||||||
|
// field read 35 yet passed the old lax validation, and max-frame-based
|
||||||
|
// fps logic reported "36 fps" to the dashboard forever after. Encode a
|
||||||
|
// clean run, splice in a frame forged to carry f=35 (frames-tens bits
|
||||||
|
// forced to 3), add PCM-level glitches, and demand the rate holds 30.
|
||||||
|
const enc = new LtcEncoder(SR, 30, false, { h: 6, m: 0, s: 0, f: 0 });
|
||||||
|
const clean1 = enc.render(SR); // ~30 valid frames
|
||||||
|
enc.bits = frameBits({ h: 6, m: 0, s: 1, f: 5 }, false);
|
||||||
|
enc.bits[8] = 1; // frames-tens bit 0
|
||||||
|
enc.bits[9] = 1; // frames-tens bit 1 → tens=3 → frames field reads 35
|
||||||
|
enc.bitIdx = 0;
|
||||||
|
const forged = enc.render(Math.ceil(SR / 30)); // one poisoned frame
|
||||||
|
const clean2 = enc.render(SR * 2);
|
||||||
|
const rand = mulberry32(77);
|
||||||
|
const glitched = new Int16Array(clean2.length);
|
||||||
|
glitched.set(clean2);
|
||||||
|
for (let at = SR / 4; at < glitched.length - 100; at += Math.floor(SR / 3)) {
|
||||||
|
for (let i = 0; i < 90; i += 1) glitched[at + i] = Math.round((rand() - 0.5) * 24000);
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
const dec = new LtcDecoder(SR, (fr) => out.push(fr));
|
||||||
|
for (const part of [clean1, forged, glitched]) {
|
||||||
|
for (let off = 0; off < part.length; off += 997) {
|
||||||
|
dec.push(part.subarray(off, Math.min(off + 997, part.length)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check('still decodes through the damage', out.length >= 70, `${out.length}`);
|
||||||
|
check('no frame number above 29 survives', out.every((fr) => fr.f <= 29));
|
||||||
|
check('fps never exceeds 30', out.every((fr) => fr.fps <= 30), `saw ${Math.max(...out.map((fr) => fr.fps))}`);
|
||||||
|
check('fps ends locked at 30', out.length > 0 && out[out.length - 1].fps === 30);
|
||||||
|
check('df never flashes on', out.every((fr) => !fr.df));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('multichannel extraction (LTC on channel 5 of 8):');
|
||||||
|
{
|
||||||
|
// Interleave LTC into one channel of eight, with hot program audio on
|
||||||
|
// every other channel, and feed the byte stream in 997-byte chunks —
|
||||||
|
// odd on purpose, so chunk splits land mid-sample and mid-frame and
|
||||||
|
// the extractor's carry has to keep frame alignment.
|
||||||
|
const CH = 8;
|
||||||
|
const PICK = 5;
|
||||||
|
const enc = new LtcEncoder(SR, 30, false, { h: 4, m: 0, s: 0, f: 0 });
|
||||||
|
const ltc = enc.render(SR * 2); // 2 s ≈ 60 frames
|
||||||
|
const bytes = Buffer.alloc(ltc.length * CH * 2);
|
||||||
|
for (let i = 0; i < ltc.length; i += 1) {
|
||||||
|
for (let c = 0; c < CH; c += 1) {
|
||||||
|
const v = c === PICK - 1 ? ltc[i] : Math.round(20000 * Math.sin(i * (0.02 + c * 0.05)));
|
||||||
|
bytes.writeInt16LE(v, (i * CH + c) * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const extractor = new ChannelExtractor(CH, PICK);
|
||||||
|
const out = [];
|
||||||
|
const dec = new LtcDecoder(SR, (f) => out.push(f));
|
||||||
|
for (let off = 0; off < bytes.length; off += 997) {
|
||||||
|
dec.push(extractor.push(bytes.subarray(off, Math.min(off + 997, bytes.length))));
|
||||||
|
}
|
||||||
|
check('decodes most frames', out.length >= 55, `${out.length}/60`);
|
||||||
|
check('starts at 04:00:00:0x', out.length > 0 && out[0].h === 4 && out[0].m === 0 && out[0].s === 0);
|
||||||
|
check('frames advance', sequential(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(failures ? `\n${failures} check(s) FAILED` : '\nAll checks passed');
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
selftest();
|
||||||
@@ -1,28 +1,123 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-timers",
|
"id": "propresenter-timers",
|
||||||
"name": "Timers",
|
"name": "Timers",
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"description": "Live ProPresenter timers and LTC timecode",
|
"proddash": ">=1.4.0",
|
||||||
|
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||||
|
"provides": ["timers"],
|
||||||
|
"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",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
"minSize": { "w": 2, "h": 1 },
|
"minSize": { "w": 2, "h": 1 },
|
||||||
"defaultSize": { "w": 4, "h": 2 },
|
"defaultSize": { "w": 4, "h": 3 },
|
||||||
"configSchema": {
|
"configGroups": {
|
||||||
"host": {
|
"ProPresenter": {
|
||||||
"type": "string",
|
"toggle": "ppEnabled",
|
||||||
"label": "ProPresenter host / IP",
|
"help": "The timers configured in ProPresenter, read over its network API. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Switch it off on a day ProPresenter isn't running and nothing is polled."
|
||||||
"default": ""
|
|
||||||
},
|
},
|
||||||
"port": {
|
"LTC Timecode": {
|
||||||
"type": "number",
|
"toggle": "ltcEnabled",
|
||||||
"label": "ProPresenter network port",
|
"help": "SMPTE LTC decoded from an audio input on this machine, shown as a running timecode. Pick the device and the channel the timecode arrives on; macOS has to grant ProdDash the microphone (see the launcher's Permissions)."
|
||||||
"default": 1025
|
},
|
||||||
|
"PCO Plan": {
|
||||||
|
"toggle": "pcoEnabled",
|
||||||
|
"help": "Timers the PCO Plan module offers — service elapsed, current item, countdown to service — appear here under a PCO Plan heading when that module is installed and configured. Nothing to set up on this side."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configSchema": {
|
||||||
|
"ppEnabled": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "ProPresenter timers",
|
||||||
|
"group": "ProPresenter",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"propresenter": {
|
||||||
|
"type": "endpoint",
|
||||||
|
"label": "ProPresenter",
|
||||||
|
"group": "ProPresenter",
|
||||||
|
"default": { "host": "", "port": 1025 }
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
"label": "API password (only if set in ProPresenter)",
|
"label": "API password",
|
||||||
"default": ""
|
"group": "ProPresenter",
|
||||||
|
"default": "",
|
||||||
|
"help": "Only if a password is set in ProPresenter's network preferences."
|
||||||
|
},
|
||||||
|
"ltcEnabled": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "LTC timecode",
|
||||||
|
"group": "LTC Timecode",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"ltcDevice": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Audio device",
|
||||||
|
"group": "LTC Timecode",
|
||||||
|
"showWhen": "ltcEnabled",
|
||||||
|
"default": "",
|
||||||
|
"optionsRoute": "/ltc/devices"
|
||||||
|
},
|
||||||
|
"ltcChannel": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Channel",
|
||||||
|
"group": "LTC Timecode",
|
||||||
|
"showWhen": "ltcEnabled",
|
||||||
|
"default": 1,
|
||||||
|
"help": "The channel on that device carrying the timecode, counted from 1."
|
||||||
|
},
|
||||||
|
"pcoEnabled": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "PCO Plan timers",
|
||||||
|
"group": "PCO Plan",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"timerFont": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Timer font",
|
||||||
|
"group": "Display",
|
||||||
|
"default": "default",
|
||||||
|
"options": [
|
||||||
|
{ "value": "default", "label": "Default (system)" },
|
||||||
|
{ "value": "monospace", "label": "Monospace — steadier changing digits" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"instanceGroups": {
|
||||||
|
"Show": { "collapsed": false, "columns": 2 },
|
||||||
|
"Clock": { "collapsed": false },
|
||||||
|
"Colours": {
|
||||||
|
"collapsed": true,
|
||||||
|
"columns": 2,
|
||||||
|
"help": "The time's colour per state. Left at the default, a colour follows the dashboard theme; one you change here wins over it."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instanceSchema": {
|
||||||
|
"showClock": { "type": "boolean", "label": "Clock", "default": true, "group": "Show" },
|
||||||
|
"showProPresenter": { "type": "boolean", "label": "ProPresenter timers", "default": true, "group": "Show" },
|
||||||
|
"showLtc": { "type": "boolean", "label": "LTC timecode", "default": true, "group": "Show" },
|
||||||
|
"showModules": { "type": "boolean", "label": "Module timers", "default": true, "group": "Show" },
|
||||||
|
"showHeadings": { "type": "boolean", "label": "Source headings", "default": true, "group": "Show" },
|
||||||
|
"showStatus": { "type": "boolean", "label": "Status lines", "default": true, "group": "Show" },
|
||||||
|
"clockFormat": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Clock format",
|
||||||
|
"default": "24h",
|
||||||
|
"group": "Clock",
|
||||||
|
"options": [
|
||||||
|
{ "value": "24h", "label": "24-hour" },
|
||||||
|
{ "value": "12h", "label": "12-hour" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"clockSeconds": { "type": "boolean", "label": "Seconds", "default": true, "group": "Clock" },
|
||||||
|
"clockDate": { "type": "boolean", "label": "Date", "default": false, "group": "Clock" },
|
||||||
|
"runningColor": { "type": "color", "label": "Running", "default": "#2ee59a", "group": "Colours" },
|
||||||
|
"warningColor": { "type": "color", "label": "Warning", "default": "#f0b429", "group": "Colours" },
|
||||||
|
"overrunColor": { "type": "color", "label": "Overrun", "default": "#f4433c", "group": "Colours" },
|
||||||
|
"idleColor": { "type": "color", "label": "Idle", "default": "#8b98a5", "group": "Colours" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1026
-276
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,16 @@
|
|||||||
/* ProPresenter Timers module — scoped to its tiles, shell variables only.
|
/* Timers module — scoped to its tiles, shell variables only.
|
||||||
The API exposes no per-timer color, so a card's color IS its state:
|
A card's colour IS its state: running / warning / overrun / idle. The four
|
||||||
accent = running, muted = stopped, warn = complete, danger = overrun.
|
tones are custom properties on the tile root: they default to the shell's
|
||||||
Overrun must be unmistakable at a distance — danger fill + pulse. */
|
palette (so a dashboard theme reaches them) and the tile's own colour
|
||||||
|
settings, when changed, are set inline on .tm-wrap and win — the
|
||||||
|
documented mechanism. Overrun must be unmistakable across the room:
|
||||||
|
danger fill + pulse. */
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-wrap {
|
[data-module="propresenter-timers"] .tm-wrap {
|
||||||
--tm-time-size: 32px;
|
--tm-ok: var(--accent);
|
||||||
--tm-ltc-size: 26px;
|
--tm-warn: var(--warn);
|
||||||
|
--tm-danger: var(--danger);
|
||||||
|
--tm-muted: var(--muted);
|
||||||
--tm-meta-size: 11px;
|
--tm-meta-size: 11px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -15,7 +20,7 @@
|
|||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── degraded-state banner (feed lost / ProPresenter unreachable) ── */
|
/* ── degraded-state banner (the ProdDash feed itself is down) ── */
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-banner {
|
[data-module="propresenter-timers"] .tm-banner {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -30,8 +35,11 @@
|
|||||||
}
|
}
|
||||||
[data-module="propresenter-timers"] .tm-banner[hidden] { display: none; }
|
[data-module="propresenter-timers"] .tm-banner[hidden] { display: none; }
|
||||||
|
|
||||||
/* Values still on screen while the feed is down are stale — say so visually. */
|
/* Values still on screen while their source is down are stale — say so
|
||||||
[data-module="propresenter-timers"] .tm-wrap.is-stale .tm-grid { opacity: 0.45; }
|
visually, per group: the LTC card keeps ticking when only ProPresenter
|
||||||
|
is unreachable. When the ProdDash feed is down everything is old. */
|
||||||
|
[data-module="propresenter-timers"] .tm-group.is-stale .tm-card,
|
||||||
|
[data-module="propresenter-timers"] .tm-wrap.is-feed-down .tm-card { opacity: 0.45; }
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-hint {
|
[data-module="propresenter-timers"] .tm-hint {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -45,7 +53,69 @@
|
|||||||
}
|
}
|
||||||
[data-module="propresenter-timers"] .tm-hint[hidden] { display: none; }
|
[data-module="propresenter-timers"] .tm-hint[hidden] { display: none; }
|
||||||
|
|
||||||
/* ── card grid (column count set by client.js fit()) ── */
|
/* ── source groups: heading + card grid (column count set by fit()) ── */
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-groups {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--tm-gap, 8px);
|
||||||
|
padding: var(--tm-pad, 10px);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-groups[hidden] { display: none; }
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-group {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-head {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0 2px 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-head[hidden] { display: none; }
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-title {
|
||||||
|
flex: none;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The source's own word while it isn't OK ("Unreachable at …"). */
|
||||||
|
[data-module="propresenter-timers"] .tm-note {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-group.is-down .tm-note { color: var(--tm-warn); }
|
||||||
|
[data-module="propresenter-timers"] .tm-group.is-stale .tm-note { color: var(--tm-danger); }
|
||||||
|
|
||||||
|
/* Headings off: a source in trouble, or with nothing to show, still says so. */
|
||||||
|
[data-module="propresenter-timers"] .tm-empty {
|
||||||
|
flex: none;
|
||||||
|
padding: 2px 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-empty[hidden] { display: none; }
|
||||||
|
[data-module="propresenter-timers"] .tm-group.is-down .tm-empty { color: var(--tm-warn); }
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-grid {
|
[data-module="propresenter-timers"] .tm-grid {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -53,17 +123,16 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-rows: 1fr;
|
grid-auto-rows: 1fr;
|
||||||
gap: var(--tm-gap, 8px);
|
gap: var(--tm-gap, 8px);
|
||||||
padding: var(--tm-pad, 10px);
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
}
|
||||||
[data-module="propresenter-timers"] .tm-grid[hidden] { display: none; }
|
[data-module="propresenter-timers"] .tm-grid:empty { display: none; }
|
||||||
|
|
||||||
/* Compact cards (short tiles): the state line goes, its color stays.
|
/* Compact cards (short tiles) and "Status lines" off: the lines under the
|
||||||
The one exception is the LTC card with no timecode API behind it —
|
digits go, the colour stays. A note that explains a missing value (no
|
||||||
that message must never be hidden. */
|
LTC input, a solo timer no longer offered) must never be hidden. */
|
||||||
[data-module="propresenter-timers"] .tm-compact .tm-card .tm-state { display: none; }
|
[data-module="propresenter-timers"] .tm-compact .tm-status:not(.is-note),
|
||||||
[data-module="propresenter-timers"] .tm-compact .tm-card-ltc.is-ltc-unavailable .tm-state { display: block; }
|
[data-module="propresenter-timers"] .tm-compact .tm-detail,
|
||||||
[data-module="propresenter-timers"] .tm-compact .tm-card.is-missing .tm-state { display: block; }
|
[data-module="propresenter-timers"] .tm-nostatus .tm-status:not(.is-note),
|
||||||
|
[data-module="propresenter-timers"] .tm-nostatus .tm-detail { display: none; }
|
||||||
|
|
||||||
/* ── one timer card ── */
|
/* ── one timer card ── */
|
||||||
|
|
||||||
@@ -80,6 +149,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: color-mix(in srgb, var(--bg) 60%, transparent);
|
background: color-mix(in srgb, var(--bg) 60%, transparent);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-name {
|
[data-module="propresenter-timers"] .tm-name {
|
||||||
@@ -94,13 +164,20 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The label is its own box so a long name ends in an ellipsis (a flex
|
||||||
|
container's bare text won't). */
|
||||||
|
[data-module="propresenter-timers"] .tm-label {
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-time {
|
[data-module="propresenter-timers"] .tm-time {
|
||||||
flex: none;
|
flex: none;
|
||||||
font-size: var(--tm-time-size);
|
font-size: 32px; /* fit() sets the real size per card */
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
@@ -109,7 +186,14 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-state {
|
/* Admin "Timer font: Monospace" — steadier digits as the numbers change. */
|
||||||
|
[data-module="propresenter-timers"] .tm-mono .tm-time {
|
||||||
|
font-family: ui-monospace, "SF Mono", "Menlo", "Cascadia Mono", "Consolas", monospace;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-status,
|
||||||
|
[data-module="propresenter-timers"] .tm-detail {
|
||||||
flex: none;
|
flex: none;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
font-size: var(--tm-meta-size);
|
font-size: var(--tm-meta-size);
|
||||||
@@ -121,100 +205,98 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-status[hidden],
|
||||||
|
[data-module="propresenter-timers"] .tm-detail[hidden] { display: none; }
|
||||||
|
|
||||||
/* ── state looks ── */
|
[data-module="propresenter-timers"] .tm-detail {
|
||||||
|
font-weight: 600;
|
||||||
[data-module="propresenter-timers"] .tm-card.is-running {
|
letter-spacing: 0.04em;
|
||||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
|
text-transform: none;
|
||||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
opacity: 0.85;
|
||||||
}
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-running .tm-time { color: var(--accent); }
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-running .tm-state { color: var(--accent); }
|
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-stopped .tm-time { color: var(--muted); }
|
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-complete {
|
|
||||||
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
|
|
||||||
background: color-mix(in srgb, var(--warn) 10%, transparent);
|
|
||||||
}
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-complete .tm-time { color: var(--warn); }
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-complete .tm-state { color: var(--warn); }
|
|
||||||
|
|
||||||
/* A solo tile whose timer was deleted in ProPresenter. */
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; }
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-missing .tm-time { color: var(--muted); }
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-missing .tm-state { color: var(--warn); }
|
|
||||||
|
|
||||||
/* Overrun: readable across the room even on a wall display. */
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-overrun {
|
|
||||||
border-color: var(--danger);
|
|
||||||
background: color-mix(in srgb, var(--danger) 22%, transparent);
|
|
||||||
animation: tm-overrun-pulse 1.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-time { color: var(--danger); }
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-state {
|
|
||||||
color: var(--danger);
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes tm-overrun-pulse {
|
/* A provider's status line carries its own tone ("ahead 1:20" in green). */
|
||||||
50% { background: color-mix(in srgb, var(--danger) 38%, transparent); }
|
[data-module="propresenter-timers"] .tm-status[data-tone="ok"] { color: var(--tm-ok); }
|
||||||
}
|
[data-module="propresenter-timers"] .tm-status[data-tone="warn"] { color: var(--tm-warn); }
|
||||||
|
[data-module="propresenter-timers"] .tm-status[data-tone="danger"] { color: var(--tm-danger); }
|
||||||
|
[data-module="propresenter-timers"] .tm-status[data-tone="muted"] { color: var(--tm-muted); }
|
||||||
|
|
||||||
/* ── the LTC card: visually its own thing ── */
|
/* Explanatory notes read as sentences, not badges. */
|
||||||
|
[data-module="propresenter-timers"] .tm-status.is-note {
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc {
|
|
||||||
border-style: dashed;
|
|
||||||
}
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc .tm-time {
|
|
||||||
font-size: var(--tm-ltc-size);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-ltc-badge {
|
|
||||||
flex: none;
|
|
||||||
padding: 0.15em 0.55em;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: color-mix(in srgb, var(--accent) 24%, transparent);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 0.95em;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving {
|
|
||||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
|
|
||||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
|
||||||
}
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving .tm-time { color: var(--accent); }
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving .tm-state { color: var(--accent); }
|
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal {
|
|
||||||
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
|
|
||||||
}
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal .tm-time { color: var(--muted); }
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal .tm-state {
|
|
||||||
color: var(--warn);
|
|
||||||
animation: tm-nosignal-blink 1.6s steps(2) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
|
|
||||||
|
|
||||||
/* No timecode API on this ProPresenter: say so, quietly. */
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-time { color: var(--muted); }
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-state {
|
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-waiting .tm-time { color: var(--muted); }
|
/* ── state looks (feedbacks) ── */
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-ok {
|
||||||
|
border-color: color-mix(in srgb, var(--tm-ok) 55%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--tm-ok) 10%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-ok .tm-time { color: var(--tm-ok); }
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-warn {
|
||||||
|
border-color: color-mix(in srgb, var(--tm-warn) 55%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--tm-warn) 10%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-warn .tm-time { color: var(--tm-warn); }
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-muted .tm-time { color: var(--tm-muted); }
|
||||||
|
|
||||||
|
/* The wall clock is information, not a state — plain text colour. */
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-plain .tm-time { color: var(--text); }
|
||||||
|
|
||||||
|
/* Overrun: readable across the room even on a wall display. */
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-danger {
|
||||||
|
border-color: var(--tm-danger);
|
||||||
|
background: color-mix(in srgb, var(--tm-danger) 22%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-danger .tm-time { color: var(--tm-danger); }
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-overrun {
|
||||||
|
animation: tm-overrun-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-status { font-weight: 800; }
|
||||||
|
|
||||||
|
@keyframes tm-overrun-pulse {
|
||||||
|
50% { background: color-mix(in srgb, var(--tm-danger) 38%, transparent); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A solo tile whose timer is no longer offered. */
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; }
|
||||||
|
|
||||||
|
/* ── the LTC card: visually its own thing ── */
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-card-ltc { border-style: dashed; }
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-ltc-badge {
|
||||||
|
flex: none;
|
||||||
|
padding: 0.15em 0.55em;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: color-mix(in srgb, var(--tm-ok) 24%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95em;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No signal: muted digits, a blinking warning line. */
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-nosignal {
|
||||||
|
border-color: color-mix(in srgb, var(--tm-warn) 55%, var(--border));
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-nosignal .tm-status {
|
||||||
|
animation: tm-nosignal-blink 1.6s steps(2) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
|
||||||
|
|
||||||
/* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */
|
/* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-menu-item {
|
[data-module="propresenter-timers"] .tm-menu-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px 9px;
|
padding: 6px 9px;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -228,16 +310,41 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
[data-module="propresenter-timers"] .tm-menu-item:hover { background: var(--border); }
|
[data-module="propresenter-timers"] .tm-menu-item:hover { background: var(--border); }
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-menu-item .check {
|
|
||||||
width: 14px;
|
|
||||||
flex: none;
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: 700;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
[data-module="propresenter-timers"] .tm-menu-item.on .check { visibility: visible; }
|
|
||||||
[data-module="propresenter-timers"] .tm-menu-item .name { font-weight: 600; }
|
[data-module="propresenter-timers"] .tm-menu-item .name { font-weight: 600; }
|
||||||
|
[data-module="propresenter-timers"] .tm-menu-item:not(.on) .name { color: var(--muted); }
|
||||||
|
|
||||||
|
/* A small switch: off = hollow pill, on = filled with the accent. (The menu
|
||||||
|
hangs off the tile's title bar, outside .tm-wrap, so the tile's own colour
|
||||||
|
properties don't reach it — the shell's palette does.) */
|
||||||
|
[data-module="propresenter-timers"] .tm-switch {
|
||||||
|
flex: none;
|
||||||
|
position: relative;
|
||||||
|
width: 26px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--muted) 25%, transparent);
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-switch::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
left: 1px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch::after {
|
||||||
|
transform: translateX(12px);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-menu-note {
|
[data-module="propresenter-timers"] .tm-menu-note {
|
||||||
padding: 6px 9px;
|
padding: 6px 9px;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "proddash",
|
||||||
|
"version": "1.4.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
+341
-3
@@ -35,6 +35,18 @@ body { overflow: auto; }
|
|||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-storage { margin-top: -6px; font-size: 12px; }
|
||||||
|
.admin-storage code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-card {
|
.admin-card {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -82,6 +94,35 @@ body { overflow: auto; }
|
|||||||
}
|
}
|
||||||
.module-health.error { color: var(--danger); }
|
.module-health.error { color: var(--danger); }
|
||||||
|
|
||||||
|
/* a module card's own status line ("polling timers on 10.3.11.20:1025",
|
||||||
|
"no ProPresenter host configured"): a readout, so it sits in a bordered
|
||||||
|
monospace box a shade lighter than the card; ok / connecting / error
|
||||||
|
show as the text (and border) colour, the box itself never changes */
|
||||||
|
.module-status {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--text) 5%, var(--panel));
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.module-status:empty { display: none; }
|
||||||
|
.module-status.ok { color: var(--text); }
|
||||||
|
.module-status.connecting {
|
||||||
|
color: var(--warn);
|
||||||
|
border-color: color-mix(in srgb, var(--warn) 40%, var(--border));
|
||||||
|
}
|
||||||
|
.module-status.error {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 40%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
.module-mount-error {
|
.module-mount-error {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@@ -93,14 +134,180 @@ body { overflow: auto; }
|
|||||||
|
|
||||||
.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
|
.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||||
.module-form .field { max-width: 420px; }
|
.module-form .field { max-width: 420px; }
|
||||||
|
|
||||||
|
/* endpoint fields: every module's upstream is entered the same way */
|
||||||
|
.field .endpoint-row { display: flex; align-items: center; gap: 7px; }
|
||||||
|
.endpoint-row .ep-host { flex: 1; min-width: 0; }
|
||||||
|
.endpoint-row .ep-port { width: 92px; flex: none; }
|
||||||
|
.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; }
|
||||||
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
|
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
|
||||||
|
|
||||||
.module-actions {
|
/* labeled config subsections (manifest field `group`) */
|
||||||
|
.field-group {
|
||||||
|
margin: 14px 0 4px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
.field-group-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 7px;
|
||||||
margin-top: 4px;
|
position: relative; /* the ⓘ popover hangs off this row */
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
.field-group-name { min-width: 0; }
|
||||||
|
.field-group .field:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* group help (configGroups[name].help): an ⓘ beside the title. Hover or
|
||||||
|
focus peeks at the text; a click pins it open until the next click. */
|
||||||
|
.group-info { display: inline-flex; align-items: center; }
|
||||||
|
.group-info-btn {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--muted);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font: italic 700 10px/1 ui-serif, Georgia, "Times New Roman", serif;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.group-info-btn:hover,
|
||||||
|
.group-info.is-open .group-info-btn { color: var(--text); border-color: var(--text); }
|
||||||
|
.group-info-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.group-info-pop {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
z-index: 5;
|
||||||
|
max-width: min(440px, 100%);
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: color-mix(in srgb, var(--text) 6%, var(--panel));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-transform: none;
|
||||||
|
white-space: normal;
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
/* keeps the popover open while the pointer crosses the gap down to it */
|
||||||
|
.group-info-pop::before { content: ""; position: absolute; left: 0; right: 0; top: -8px; height: 8px; }
|
||||||
|
.group-info:hover .group-info-pop,
|
||||||
|
.group-info-btn:focus-visible + .group-info-pop,
|
||||||
|
.group-info.is-open .group-info-pop { display: block; }
|
||||||
|
|
||||||
|
/* group toggle (configGroups[name].toggle): the named switch sits at the
|
||||||
|
right of the title row as the group's on/off; the body dims while it is
|
||||||
|
off but stays editable */
|
||||||
|
.field-group-toggle {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.field-group-toggle .field {
|
||||||
|
margin: 0;
|
||||||
|
max-width: none;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-transform: none;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.field-group-toggle .field.switch-field { flex-direction: row-reverse; gap: 8px; }
|
||||||
|
.field-group-toggle .field.switch-field .switch-label { font-size: 12px; }
|
||||||
|
.field-group-toggle .field.check { flex-direction: row-reverse; gap: 8px; }
|
||||||
|
.field-group.is-off > .field-group-body { opacity: 0.45; transition: opacity 0.15s; }
|
||||||
|
.field-group.has-toggle > .field-group-body { transition: opacity 0.15s; }
|
||||||
|
|
||||||
|
/* collapsible groups (manifest configGroups[name].collapsed) */
|
||||||
|
details.field-group > summary.field-group-title {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
details.field-group > summary.field-group-title::-webkit-details-marker { display: none; }
|
||||||
|
details.field-group > summary.field-group-title::before {
|
||||||
|
content: "";
|
||||||
|
width: 0; height: 0;
|
||||||
|
border-left: 5px solid var(--muted);
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
border-bottom: 4px solid transparent;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
details.field-group[open] > summary.field-group-title::before { transform: rotate(90deg); }
|
||||||
|
details.field-group[open] > summary.field-group-title { margin-bottom: 10px; }
|
||||||
|
details.field-group > summary.field-group-title:hover { color: var(--text); }
|
||||||
|
|
||||||
|
/* multi-column groups (configGroups[name].columns): a neat checklist grid */
|
||||||
|
.field-group-body.is-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
|
||||||
|
gap: 4px 16px;
|
||||||
|
}
|
||||||
|
.field-group-body.is-columns .field { margin-bottom: 4px; max-width: none; }
|
||||||
|
@media (max-width: 560px) { .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } }
|
||||||
|
|
||||||
|
/* one line of guidance under a control (field `help`) */
|
||||||
|
.field-help {
|
||||||
|
display: block;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||||
|
|
||||||
|
/* showWhen: a hidden dependent field must actually collapse — `.field`'s
|
||||||
|
own `display:flex` (an author rule) otherwise outranks the [hidden] UA
|
||||||
|
default and the field stays visible. */
|
||||||
|
.module-form .field[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* a boolean rendered as a toggle (manifest type `switch`) */
|
||||||
|
.field.switch-field {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
.field.switch-field .switch-label { font-size: 13px; }
|
||||||
|
|
||||||
|
/* the card's one action row: status on the left, Configure / Apply on the right */
|
||||||
|
.module-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end; /* the button stays bottom-right when a readout wraps */
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.module-name.is-toggle { cursor: pointer; }
|
||||||
|
.module-name.is-toggle:hover,
|
||||||
|
.module-name.is-toggle:focus-visible { color: var(--accent); outline: none; }
|
||||||
|
.module-actions .admin-status { flex: 1; margin-top: 0; }
|
||||||
|
.module-actions .module-status { flex: 0 1 auto; min-width: 0; margin-top: 0; }
|
||||||
|
.module-actions .btn { flex: none; margin-left: auto; }
|
||||||
|
|
||||||
.admin-status { min-height: 16px; font-size: 12px; color: var(--muted); margin-top: 8px; }
|
.admin-status { min-height: 16px; font-size: 12px; color: var(--muted); margin-top: 8px; }
|
||||||
.admin-status.error { color: var(--danger); }
|
.admin-status.error { color: var(--danger); }
|
||||||
@@ -152,3 +359,134 @@ body { overflow: auto; }
|
|||||||
.layout-row .name { flex: 1; font-size: 13px; font-weight: 600; }
|
.layout-row .name { flex: 1; font-size: 13px; font-weight: 600; }
|
||||||
.layout-row .meta { color: var(--muted); font-size: 11px; }
|
.layout-row .meta { color: var(--muted); font-size: 11px; }
|
||||||
.layouts-empty { color: var(--muted); font-size: 13px; }
|
.layouts-empty { color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
|
/* ── version, update banner, catalog ───────────────────────────────── */
|
||||||
|
|
||||||
|
.admin-version {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-left: 4px;
|
||||||
|
vertical-align: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-card {
|
||||||
|
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--warn) 7%, var(--panel));
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
.update-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.update-head strong { font-size: 14px; }
|
||||||
|
.update-head .spacer { flex: 1; }
|
||||||
|
.update-card .admin-copy { margin: 8px 0 0; }
|
||||||
|
|
||||||
|
.btn.small { padding: 4px 9px; font-size: 12px; }
|
||||||
|
.btn.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); }
|
||||||
|
.btn.danger:hover { background: color-mix(in srgb, var(--danger) 14%, var(--bg)); }
|
||||||
|
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
a.btn { text-decoration: none; }
|
||||||
|
|
||||||
|
.module-source {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
}
|
||||||
|
.module-update-slot:empty { display: none; }
|
||||||
|
|
||||||
|
.catalog-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.catalog-toolbar .admin-status { margin-top: 0; }
|
||||||
|
|
||||||
|
.catalog-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.catalog-main { flex: 1; min-width: 0; }
|
||||||
|
.catalog-title { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.catalog-row .module-desc { margin: 4px 0 2px; }
|
||||||
|
.catalog-row .module-health { margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ── theme ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* one swatch per preset: a miniature dashboard (page, a tile on it, the
|
||||||
|
accent as a status dot) and the name; the current one wears the accent */
|
||||||
|
.theme-swatches {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.theme-swatch {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.theme-swatch:hover { border-color: var(--muted); }
|
||||||
|
.theme-swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.theme-swatch.is-current { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||||
|
.theme-preview {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--sw-bg);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--sw-panel) 60%, var(--sw-accent));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.theme-preview::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 8px; right: 8px; top: 10px; bottom: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--sw-panel);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--sw-panel) 75%, var(--sw-accent));
|
||||||
|
}
|
||||||
|
.theme-preview::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 14px; top: 16px;
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--sw-accent);
|
||||||
|
box-shadow: 0 0 6px var(--sw-accent);
|
||||||
|
}
|
||||||
|
.theme-name { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.theme-swatch.is-current .theme-name::after {
|
||||||
|
content: "current";
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
#theme-status { margin-top: 4px; }
|
||||||
|
|||||||
+756
-29
@@ -12,6 +12,22 @@ const modulesList = document.getElementById('modules-list');
|
|||||||
const layoutsSection = document.getElementById('layouts-section');
|
const layoutsSection = document.getElementById('layouts-section');
|
||||||
const layoutsList = document.getElementById('layouts-list');
|
const layoutsList = document.getElementById('layouts-list');
|
||||||
const toastEl = document.getElementById('toast');
|
const toastEl = document.getElementById('toast');
|
||||||
|
const availableSection = document.getElementById('available-section');
|
||||||
|
const availableList = document.getElementById('available-list');
|
||||||
|
const catalogStatus = document.getElementById('catalog-status');
|
||||||
|
const updateCard = document.getElementById('update-card');
|
||||||
|
const updateTitle = document.getElementById('update-title');
|
||||||
|
const updateCopy = document.getElementById('update-copy');
|
||||||
|
const updateStatus = document.getElementById('update-status');
|
||||||
|
const updateBtn = document.getElementById('update-btn');
|
||||||
|
const updateChanges = document.getElementById('update-changes');
|
||||||
|
const shellVersionEl = document.getElementById('shell-version');
|
||||||
|
const themeSection = document.getElementById('theme-section');
|
||||||
|
const themeSwatches = document.getElementById('theme-swatches');
|
||||||
|
const themeStatus = document.getElementById('theme-status');
|
||||||
|
|
||||||
|
/** The shell version the page loaded with — the update flow watches it change. */
|
||||||
|
let shellVersion = '';
|
||||||
|
|
||||||
let toastTimer = null;
|
let toastTimer = null;
|
||||||
function toast(msg, isError = false) {
|
function toast(msg, isError = false) {
|
||||||
@@ -42,7 +58,10 @@ async function api(path, opts = {}) {
|
|||||||
function showLogin() {
|
function showLogin() {
|
||||||
loginCard.hidden = false;
|
loginCard.hidden = false;
|
||||||
modulesSection.hidden = true;
|
modulesSection.hidden = true;
|
||||||
|
availableSection.hidden = true;
|
||||||
layoutsSection.hidden = true;
|
layoutsSection.hidden = true;
|
||||||
|
updateCard.hidden = true;
|
||||||
|
themeSection.hidden = true;
|
||||||
passcodeInput.focus();
|
passcodeInput.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,32 +102,143 @@ function healthText(mod) {
|
|||||||
return mod.hasServer ? 'Mounted.' : 'Client-only module (no server part).';
|
return mod.hasServer ? 'Mounted.' : 'Client-only module (no server part).';
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldFor(key, spec, value, passwordSet) {
|
function fieldFor(key, spec, value, passwordSet, moduleId) {
|
||||||
const type = spec?.type || 'string';
|
const type = spec?.type || 'string';
|
||||||
const label = spec?.label || key;
|
const label = spec?.label || key;
|
||||||
const field = document.createElement('label');
|
const field = document.createElement('label');
|
||||||
field.className = 'field' + (type === 'boolean' ? ' check' : '');
|
field.className = 'field' + (type === 'boolean' ? ' check' : '') + (type === 'switch' ? ' switch-field' : '');
|
||||||
let read;
|
let read;
|
||||||
|
// For boolean/switch fields, the control other fields may depend on
|
||||||
|
// (showWhen) — returned so the caller can wire visibility.
|
||||||
|
let control = null;
|
||||||
|
// For selects: the element (optionsDependsOn sources) and, with an
|
||||||
|
// optionsRoute, a function that (re)loads the options.
|
||||||
|
let selectEl = null;
|
||||||
|
let refetch = null;
|
||||||
|
|
||||||
if (type === 'boolean') {
|
if (type === 'switch') {
|
||||||
|
// Same toggle as the module enable switch, inline with its label.
|
||||||
|
const toggle = document.createElement('span');
|
||||||
|
toggle.className = 'switch';
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'checkbox';
|
||||||
|
input.checked = Boolean(value);
|
||||||
|
const track = document.createElement('span');
|
||||||
|
track.className = 'track';
|
||||||
|
toggle.append(input, track);
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.className = 'switch-label';
|
||||||
|
text.textContent = label;
|
||||||
|
field.append(toggle, text);
|
||||||
|
read = () => input.checked;
|
||||||
|
control = input;
|
||||||
|
} else if (type === 'boolean') {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'checkbox';
|
input.type = 'checkbox';
|
||||||
input.checked = Boolean(value);
|
input.checked = Boolean(value);
|
||||||
field.append(input, document.createTextNode(label));
|
field.append(input, document.createTextNode(label));
|
||||||
read = () => input.checked;
|
read = () => input.checked;
|
||||||
} else if (type === 'select' && Array.isArray(spec.options)) {
|
control = input;
|
||||||
|
} else if (type === 'endpoint') {
|
||||||
|
// One consistent "Host / IP : Port" control for every module's upstream.
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = label;
|
||||||
|
const row = document.createElement('span');
|
||||||
|
row.className = 'endpoint-row';
|
||||||
|
const host = document.createElement('input');
|
||||||
|
host.type = 'text';
|
||||||
|
host.className = 'ep-host';
|
||||||
|
host.placeholder = 'Host / IP';
|
||||||
|
host.autocomplete = 'off';
|
||||||
|
host.spellcheck = false;
|
||||||
|
host.value = value?.host ? String(value.host) : '';
|
||||||
|
const sep = document.createElement('span');
|
||||||
|
sep.className = 'ep-sep';
|
||||||
|
sep.textContent = ':';
|
||||||
|
const port = document.createElement('input');
|
||||||
|
port.type = 'number';
|
||||||
|
port.className = 'ep-port';
|
||||||
|
port.placeholder = 'Port';
|
||||||
|
port.min = '1';
|
||||||
|
port.max = '65535';
|
||||||
|
port.value = value?.port ? String(value.port) : '';
|
||||||
|
row.append(host, sep, port);
|
||||||
|
field.append(span, row);
|
||||||
|
read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 });
|
||||||
|
} else if (type === 'select' && (Array.isArray(spec.options) || spec.optionsRoute)) {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
const select = document.createElement('select');
|
const select = document.createElement('select');
|
||||||
for (const opt of spec.options) {
|
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
|
||||||
const o = document.createElement('option');
|
const fill = (options) => {
|
||||||
o.value = String(typeof opt === 'object' ? opt.value : opt);
|
// Keep what the admin has picked but not yet saved across a refill
|
||||||
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
// (a dependent select refetching), else the saved value.
|
||||||
select.appendChild(o);
|
const saved = String(value ?? '');
|
||||||
|
const wanted = select.options.length ? select.value : saved;
|
||||||
|
select.innerHTML = '';
|
||||||
|
// The saved value must stay selectable even when it isn't among the
|
||||||
|
// live options (device unplugged, module route down).
|
||||||
|
if (saved && !options.some((opt) => optValue(opt) === saved)) {
|
||||||
|
options = [{ value: saved, label: `${saved} (saved)` }, ...options];
|
||||||
|
}
|
||||||
|
// Nothing saved yet → a blank "choose" entry, unless the module already
|
||||||
|
// offers a blank option of its own (an "Automatic" default, say).
|
||||||
|
if (!saved && !options.some((opt) => optValue(opt) === '')) {
|
||||||
|
options = [{ value: '', label: '— choose —' }, ...options];
|
||||||
|
}
|
||||||
|
// Options may carry a `group` (a folder path, a device class): options
|
||||||
|
// sharing one render under a single <optgroup>, in first-seen order.
|
||||||
|
const groups = new Map();
|
||||||
|
for (const opt of options) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = optValue(opt);
|
||||||
|
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
||||||
|
const g = typeof opt === 'object' && opt.group ? String(opt.group) : '';
|
||||||
|
if (!g) {
|
||||||
|
select.appendChild(o);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!groups.has(g)) {
|
||||||
|
const og = document.createElement('optgroup');
|
||||||
|
og.label = g;
|
||||||
|
groups.set(g, og);
|
||||||
|
select.appendChild(og);
|
||||||
|
}
|
||||||
|
groups.get(g).appendChild(o);
|
||||||
|
}
|
||||||
|
select.value = options.some((opt) => optValue(opt) === wanted) ? wanted : saved;
|
||||||
|
};
|
||||||
|
fill(Array.isArray(spec.options) ? spec.options : []);
|
||||||
|
if (spec.optionsRoute && moduleId) {
|
||||||
|
// Options discovered at runtime (audio devices, ports, sources…):
|
||||||
|
// the module serves them from one of its own routes. With
|
||||||
|
// optionsDependsOn the caller triggers the fetch (and re-fetches when
|
||||||
|
// that other field changes), passing its value as a query parameter.
|
||||||
|
refetch = (query) => {
|
||||||
|
const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : '';
|
||||||
|
return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`)
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((body) => {
|
||||||
|
if (Array.isArray(body?.options)) fill(body.options);
|
||||||
|
})
|
||||||
|
.catch(() => { /* keep the static/saved options */ });
|
||||||
|
};
|
||||||
|
if (!spec.optionsDependsOn) refetch();
|
||||||
}
|
}
|
||||||
select.value = String(value ?? '');
|
|
||||||
field.append(span, select);
|
field.append(span, select);
|
||||||
read = () => select.value;
|
read = () => select.value;
|
||||||
|
selectEl = select;
|
||||||
|
} else if (type === 'color') {
|
||||||
|
// A swatch picker; the value is always a #rrggbb string.
|
||||||
|
field.classList.add('color-field');
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = label;
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'color';
|
||||||
|
const hex = (v, fb) => (/^#[0-9a-fA-F]{6}$/.test(String(v ?? '').trim()) ? String(v).trim().toLowerCase() : fb);
|
||||||
|
input.value = hex(value, hex(spec?.default, '#2ee59a'));
|
||||||
|
field.append(span, input);
|
||||||
|
read = () => input.value;
|
||||||
} else {
|
} else {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
@@ -123,7 +253,125 @@ function fieldFor(key, spec, value, passwordSet) {
|
|||||||
field.append(span, input);
|
field.append(span, input);
|
||||||
read = () => (type === 'number' ? Number(input.value) : input.value);
|
read = () => (type === 'number' ? Number(input.value) : input.value);
|
||||||
}
|
}
|
||||||
return { field, read };
|
if (spec?.help) {
|
||||||
|
// One line of guidance under the control (allowed placeholders, where a
|
||||||
|
// value comes from…) — the label stays short.
|
||||||
|
const help = document.createElement('small');
|
||||||
|
help.className = 'field-help';
|
||||||
|
help.textContent = spec.help;
|
||||||
|
field.appendChild(help);
|
||||||
|
}
|
||||||
|
return { field, read, control, selectEl, refetch };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does a config group have anything entered? Drives `collapsed: "whenSet"`:
|
||||||
|
* a credentials block stays folded once it holds a saved secret or a value
|
||||||
|
* other than the schema default, and opens by itself while still empty.
|
||||||
|
*/
|
||||||
|
function groupHasValues(fields, mod) {
|
||||||
|
return fields.some(({ key, spec }) => {
|
||||||
|
const type = spec?.type || 'string';
|
||||||
|
if (type === 'password') return Boolean(mod.passwordSet[key]);
|
||||||
|
if (type === 'boolean' || type === 'switch') return false;
|
||||||
|
const v = mod.config[key];
|
||||||
|
if (type === 'endpoint') return Boolean(v?.host);
|
||||||
|
if (v === undefined || v === null || String(v) === '') return false;
|
||||||
|
return !('default' in (spec || {})) || String(v) !== String(spec.default);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── group title row: the ⓘ and the group toggle ────────────────────── */
|
||||||
|
|
||||||
|
let groupInfoSeq = 0;
|
||||||
|
|
||||||
|
function closeGroupInfos(except) {
|
||||||
|
for (const open of document.querySelectorAll('.group-info.is-open')) {
|
||||||
|
if (open === except) continue;
|
||||||
|
open.classList.remove('is-open');
|
||||||
|
open.querySelector('.group-info-btn')?.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* configGroups[name].help as an ⓘ beside the group title: click toggles a
|
||||||
|
* small popover with the text (hover and keyboard focus show it too), so a
|
||||||
|
* group can carry a paragraph without pushing its fields down the page. A
|
||||||
|
* real button, labelled, so it works from the keyboard; inside a <summary>
|
||||||
|
* it must never fold the group.
|
||||||
|
*/
|
||||||
|
function groupInfo(groupName, text) {
|
||||||
|
const wrap = document.createElement('span');
|
||||||
|
wrap.className = 'group-info';
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'group-info-btn';
|
||||||
|
btn.textContent = 'i';
|
||||||
|
btn.setAttribute('aria-label', `About ${groupName}`);
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
const pop = document.createElement('span');
|
||||||
|
pop.className = 'group-info-pop';
|
||||||
|
pop.id = `group-info-${++groupInfoSeq}`;
|
||||||
|
pop.setAttribute('role', 'note');
|
||||||
|
pop.textContent = text;
|
||||||
|
btn.setAttribute('aria-describedby', pop.id);
|
||||||
|
wrap.append(btn, pop);
|
||||||
|
const setOpen = (open) => {
|
||||||
|
wrap.classList.toggle('is-open', open);
|
||||||
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
};
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault(); // a click in a <summary> would otherwise fold the group
|
||||||
|
e.stopPropagation();
|
||||||
|
const open = !wrap.classList.contains('is-open');
|
||||||
|
closeGroupInfos(wrap);
|
||||||
|
setOpen(open);
|
||||||
|
});
|
||||||
|
pop.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
wrap.addEventListener('keydown', (e) => {
|
||||||
|
// Enter / Space activate the button (a click); they must not reach a
|
||||||
|
// <summary> above as well. Escape closes and hands focus back.
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') e.stopPropagation();
|
||||||
|
if (e.key === 'Escape' && wrap.classList.contains('is-open')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(false);
|
||||||
|
btn.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A click anywhere else, or Escape, closes an open popover.
|
||||||
|
document.addEventListener('pointerdown', (e) => {
|
||||||
|
if (!(e.target instanceof Element) || !e.target.closest('.group-info')) closeGroupInfos();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeGroupInfos();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* configGroups[name].toggle: the named switch/boolean field is the group's
|
||||||
|
* on/off. It moves into the title row, right-aligned, and the body dims
|
||||||
|
* while it is off (fields stay editable). The field itself is untouched —
|
||||||
|
* it is still read on save and still drives showWhen dependents.
|
||||||
|
*/
|
||||||
|
function mountGroupToggle(group, field, control) {
|
||||||
|
const slot = document.createElement('span');
|
||||||
|
slot.className = 'field-group-toggle';
|
||||||
|
slot.appendChild(field);
|
||||||
|
// Inside a <summary>, flipping the switch must not fold the group.
|
||||||
|
slot.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
slot.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') e.stopPropagation();
|
||||||
|
});
|
||||||
|
group.title.appendChild(slot);
|
||||||
|
group.el.classList.add('has-toggle');
|
||||||
|
const sync = () => group.el.classList.toggle('is-off', !control.checked);
|
||||||
|
control.addEventListener('change', sync);
|
||||||
|
sync();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildModuleCard(mod) {
|
function buildModuleCard(mod) {
|
||||||
@@ -144,9 +392,43 @@ function buildModuleCard(mod) {
|
|||||||
version.className = 'module-version';
|
version.className = 'module-version';
|
||||||
version.textContent = mod.version ? 'v' + mod.version : '';
|
version.textContent = mod.version ? 'v' + mod.version : '';
|
||||||
|
|
||||||
|
const source = document.createElement('span');
|
||||||
|
source.className = 'module-source';
|
||||||
|
source.textContent = mod.source === 'installed' ? 'installed' : 'bundled';
|
||||||
|
source.title = mod.source === 'installed'
|
||||||
|
? 'Installed from the repo into the data directory'
|
||||||
|
: 'Ships with this copy of ProdDash';
|
||||||
|
|
||||||
const spacer = document.createElement('span');
|
const spacer = document.createElement('span');
|
||||||
spacer.className = 'spacer';
|
spacer.className = 'spacer';
|
||||||
|
|
||||||
|
// filled by the catalog when the repo has a newer version
|
||||||
|
const updateSlot = document.createElement('span');
|
||||||
|
updateSlot.className = 'module-update-slot';
|
||||||
|
|
||||||
|
const uninstall = document.createElement('button');
|
||||||
|
uninstall.className = 'btn small danger';
|
||||||
|
uninstall.textContent = 'Uninstall';
|
||||||
|
uninstall.title = mod.source === 'installed'
|
||||||
|
? 'Remove this module\'s files from the data directory'
|
||||||
|
: 'Hide this bundled module from every dashboard (its folder stays with the app)';
|
||||||
|
uninstall.addEventListener('click', async () => {
|
||||||
|
const what = mod.source === 'installed'
|
||||||
|
? `Uninstall ${mod.name}? Its files are removed from the data directory. Its settings are kept in case you install it again.`
|
||||||
|
: `Uninstall ${mod.name}? It disappears from every dashboard and the picker. It ships with ProdDash, so it can be brought back from "Available modules" any time.`;
|
||||||
|
if (!window.confirm(what)) return;
|
||||||
|
uninstall.disabled = true;
|
||||||
|
try {
|
||||||
|
await api(`/api/admin/modules/${encodeURIComponent(mod.id)}`, { method: 'DELETE' });
|
||||||
|
toast(`${mod.name} uninstalled`);
|
||||||
|
await loadState();
|
||||||
|
} catch (e) {
|
||||||
|
uninstall.disabled = false;
|
||||||
|
if (e.authRequired) showLogin();
|
||||||
|
else toast(e.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const toggleLabel = document.createElement('label');
|
const toggleLabel = document.createElement('label');
|
||||||
toggleLabel.className = 'switch';
|
toggleLabel.className = 'switch';
|
||||||
const toggle = document.createElement('input');
|
const toggle = document.createElement('input');
|
||||||
@@ -158,7 +440,7 @@ function buildModuleCard(mod) {
|
|||||||
toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled';
|
toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled';
|
||||||
toggleLabel.append(toggle, track, toggleText);
|
toggleLabel.append(toggle, track, toggleText);
|
||||||
|
|
||||||
head.append(dot, name, version, spacer, toggleLabel);
|
head.append(dot, name, version, source, spacer, updateSlot, toggleLabel, uninstall);
|
||||||
card.appendChild(head);
|
card.appendChild(head);
|
||||||
|
|
||||||
const desc = document.createElement('div');
|
const desc = document.createElement('div');
|
||||||
@@ -166,18 +448,23 @@ function buildModuleCard(mod) {
|
|||||||
desc.textContent = mod.description;
|
desc.textContent = mod.description;
|
||||||
card.appendChild(desc);
|
card.appendChild(desc);
|
||||||
|
|
||||||
|
// The module's own words about its upstream ("polling timers on
|
||||||
|
// 10.3.11.20:1025") — a readout, so it sits in a bordered monospace box
|
||||||
|
// and the state (ok / connecting / error) is its text colour.
|
||||||
const healthEl = document.createElement('div');
|
const healthEl = document.createElement('div');
|
||||||
healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : '');
|
healthEl.className = 'module-status ' + healthClass(mod);
|
||||||
healthEl.textContent = healthText(mod);
|
healthEl.textContent = healthText(mod);
|
||||||
card.appendChild(healthEl);
|
card.appendChild(healthEl);
|
||||||
|
|
||||||
const mountError = document.createElement('div');
|
const mountError = document.createElement('div');
|
||||||
mountError.className = 'module-mount-error';
|
mountError.className = 'module-mount-error';
|
||||||
mountError.hidden = !mod.mountError;
|
mountError.hidden = !mod.mountError && !mod.incompatible;
|
||||||
mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
|
mountError.textContent = mod.incompatible
|
||||||
|
? `Not loaded — ${mod.incompatible}. Update ProdDash from the banner above.`
|
||||||
|
: (mod.mountError ? `Failed to start: ${mod.mountError}` : '');
|
||||||
card.appendChild(mountError);
|
card.appendChild(mountError);
|
||||||
|
|
||||||
liveEls.set(mod.id, { dot, healthEl, toggle, toggleText, mountError });
|
liveEls.set(mod.id, { dot, healthEl, toggle, toggleText, mountError, updateSlot });
|
||||||
|
|
||||||
toggle.addEventListener('change', async () => {
|
toggle.addEventListener('change', async () => {
|
||||||
try {
|
try {
|
||||||
@@ -200,36 +487,156 @@ function buildModuleCard(mod) {
|
|||||||
const form = document.createElement('div');
|
const form = document.createElement('div');
|
||||||
form.className = 'module-form';
|
form.className = 'module-form';
|
||||||
const readers = new Map();
|
const readers = new Map();
|
||||||
|
const controls = new Map(); // key → boolean/switch input (for showWhen)
|
||||||
|
const selects = new Map(); // key → select element (optionsDependsOn sources)
|
||||||
|
const dependents = []; // { field, when } to show/hide by a control's state
|
||||||
|
const optionDeps = []; // { refetch, on } selects whose options follow another field
|
||||||
|
const reloaders = []; // re-run every optionsRoute fetch (after a save changed what the module can list)
|
||||||
|
const collapsibles = []; // { el, meta, fields } groups that fold
|
||||||
|
// Fields render in schema order; a `group` starts a labeled subsection
|
||||||
|
// and consecutive same-group fields share it. The manifest's optional
|
||||||
|
// `configGroups[name]` adds { collapsed, columns, help, toggle } for
|
||||||
|
// that group: help is an ⓘ beside the title, toggle names the switch
|
||||||
|
// that is the group's on/off and lives in the title row.
|
||||||
|
const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {};
|
||||||
|
let groupName = null;
|
||||||
|
let container = form;
|
||||||
|
let currentGroup = null;
|
||||||
for (const [key, spec] of schemaKeys) {
|
for (const [key, spec] of schemaKeys) {
|
||||||
const { field, read } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key]);
|
const g = spec?.group || '';
|
||||||
|
if (g !== groupName) {
|
||||||
|
groupName = g;
|
||||||
|
currentGroup = null;
|
||||||
|
if (g) {
|
||||||
|
const meta = groupMeta[g] && typeof groupMeta[g] === 'object' ? groupMeta[g] : {};
|
||||||
|
const folds = meta.collapsed !== undefined;
|
||||||
|
const group = document.createElement(folds ? 'details' : 'div');
|
||||||
|
group.className = 'field-group';
|
||||||
|
const title = document.createElement(folds ? 'summary' : 'div');
|
||||||
|
title.className = 'field-group-title';
|
||||||
|
const titleName = document.createElement('span');
|
||||||
|
titleName.className = 'field-group-name';
|
||||||
|
titleName.textContent = g;
|
||||||
|
title.appendChild(titleName);
|
||||||
|
if (meta.help) title.appendChild(groupInfo(g, String(meta.help)));
|
||||||
|
group.appendChild(title);
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'field-group-body';
|
||||||
|
const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0);
|
||||||
|
if (cols > 1) {
|
||||||
|
body.classList.add('is-columns');
|
||||||
|
body.style.setProperty('--cols', String(cols));
|
||||||
|
}
|
||||||
|
group.appendChild(body);
|
||||||
|
form.appendChild(group);
|
||||||
|
container = body;
|
||||||
|
currentGroup = {
|
||||||
|
el: group,
|
||||||
|
title,
|
||||||
|
meta,
|
||||||
|
fields: [],
|
||||||
|
toggleKey: typeof meta.toggle === 'string' ? meta.toggle : '',
|
||||||
|
};
|
||||||
|
if (folds) collapsibles.push(currentGroup);
|
||||||
|
} else {
|
||||||
|
container = form;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { field, read, control, selectEl, refetch } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key], mod.id);
|
||||||
readers.set(key, read);
|
readers.set(key, read);
|
||||||
form.appendChild(field);
|
if (control) controls.set(key, control);
|
||||||
|
if (selectEl) selects.set(key, selectEl);
|
||||||
|
if (refetch && spec?.optionsDependsOn) optionDeps.push({ refetch, on: spec.optionsDependsOn });
|
||||||
|
else if (refetch) reloaders.push(() => refetch());
|
||||||
|
if (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
|
||||||
|
if (currentGroup) currentGroup.fields.push({ key, spec });
|
||||||
|
if (currentGroup && control && key === currentGroup.toggleKey) {
|
||||||
|
// The group's on/off: title row, not the body (never both). A
|
||||||
|
// toggle naming a non-boolean field is ignored and renders normally.
|
||||||
|
mountGroupToggle(currentGroup, field, control);
|
||||||
|
} else {
|
||||||
|
container.appendChild(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A field with showWhen:"K" is visible only while K's toggle is on.
|
||||||
|
for (const dep of dependents) {
|
||||||
|
const ctl = controls.get(dep.when);
|
||||||
|
if (!ctl) continue;
|
||||||
|
const sync = () => { dep.field.hidden = !ctl.checked; };
|
||||||
|
ctl.addEventListener('change', sync);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
// A select with optionsDependsOn:"K" loads its options with K's current
|
||||||
|
// value as a query parameter, now and whenever K changes (a plan list
|
||||||
|
// that follows the chosen service type).
|
||||||
|
for (const dep of optionDeps) {
|
||||||
|
const src = selects.get(dep.on) || controls.get(dep.on);
|
||||||
|
const run = () => dep.refetch({ [dep.on]: !src ? '' : (src.type === 'checkbox' ? String(src.checked) : src.value) });
|
||||||
|
if (src) src.addEventListener('change', run);
|
||||||
|
reloaders.push(run);
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
// Collapsible groups: `collapsed: true` starts folded, `false` open,
|
||||||
|
// "whenSet" folded only once the group already holds values.
|
||||||
|
for (const grp of collapsibles) {
|
||||||
|
const c = grp.meta.collapsed;
|
||||||
|
grp.el.open = !(c === true || (c === 'whenSet' && groupHasValues(grp.fields, mod)));
|
||||||
}
|
}
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'module-actions';
|
actions.className = 'module-actions';
|
||||||
const save = document.createElement('button');
|
const save = document.createElement('button');
|
||||||
save.className = 'btn primary';
|
|
||||||
save.textContent = 'Save & re-init';
|
|
||||||
const status = document.createElement('span');
|
const status = document.createElement('span');
|
||||||
status.className = 'admin-status';
|
status.className = 'admin-status';
|
||||||
actions.append(save, status);
|
actions.append(status, save);
|
||||||
form.appendChild(actions);
|
// A card is a status line until someone wants to change something: the
|
||||||
|
// form starts folded, and the row at the bottom holds one button that
|
||||||
|
// does both jobs — "Configure" opens the form and becomes "Apply", which
|
||||||
|
// saves without closing it. The name is the other way in and out.
|
||||||
card.appendChild(form);
|
card.appendChild(form);
|
||||||
|
card.appendChild(actions);
|
||||||
|
const openKey = 'proddash:admin:open:' + mod.id;
|
||||||
|
const setOpen = (open) => {
|
||||||
|
form.hidden = !open;
|
||||||
|
status.hidden = !open;
|
||||||
|
save.textContent = open ? 'Apply' : 'Configure';
|
||||||
|
save.className = open ? 'btn primary' : 'btn';
|
||||||
|
// Folded, the readout shares the bottom row with Configure instead of
|
||||||
|
// taking a line of its own; open, it goes back above the form.
|
||||||
|
if (open) card.insertBefore(healthEl, mountError);
|
||||||
|
else actions.insertBefore(healthEl, status);
|
||||||
|
try { sessionStorage.setItem(openKey, open ? '1' : '0'); } catch { /* fine */ }
|
||||||
|
};
|
||||||
|
let wasOpen = false;
|
||||||
|
try { wasOpen = sessionStorage.getItem(openKey) === '1'; } catch { /* fine */ }
|
||||||
|
setOpen(wasOpen);
|
||||||
|
name.classList.add('is-toggle');
|
||||||
|
name.title = 'Show or hide this module\'s settings';
|
||||||
|
name.tabIndex = 0;
|
||||||
|
name.setAttribute('role', 'button');
|
||||||
|
name.addEventListener('click', () => setOpen(form.hidden));
|
||||||
|
name.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(form.hidden); }
|
||||||
|
});
|
||||||
|
|
||||||
save.addEventListener('click', async () => {
|
save.addEventListener('click', async () => {
|
||||||
|
if (form.hidden) return setOpen(true);
|
||||||
const config = {};
|
const config = {};
|
||||||
for (const [key, read] of readers) config[key] = read();
|
for (const [key, read] of readers) config[key] = read();
|
||||||
save.disabled = true;
|
save.disabled = true;
|
||||||
status.textContent = 'Saving…';
|
status.textContent = 'Applying…';
|
||||||
status.className = 'admin-status';
|
status.className = 'admin-status';
|
||||||
try {
|
try {
|
||||||
await api(`/api/admin/modules/${encodeURIComponent(mod.id)}/config`, {
|
await api(`/api/admin/modules/${encodeURIComponent(mod.id)}/config`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ config }),
|
body: JSON.stringify({ config }),
|
||||||
});
|
});
|
||||||
status.textContent = 'Saved — module re-initialised. Open tiles reconnect on their own.';
|
status.textContent = 'Applied — module re-initialised. Open tiles reconnect on their own.';
|
||||||
status.className = 'admin-status ok';
|
status.className = 'admin-status ok';
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
|
// What the module can list may have changed (credentials just
|
||||||
|
// saved → the upstream is reachable now): reload the dynamic selects.
|
||||||
|
// The re-init is quick but not instant; give it a beat.
|
||||||
|
setTimeout(() => { for (const reload of reloaders) reload(); }, 800);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status.textContent = e.message;
|
status.textContent = e.message;
|
||||||
status.className = 'admin-status error';
|
status.className = 'admin-status error';
|
||||||
@@ -262,9 +669,11 @@ async function refreshStatus() {
|
|||||||
if (!els) continue;
|
if (!els) continue;
|
||||||
els.dot.className = 'dot ' + healthClass(mod);
|
els.dot.className = 'dot ' + healthClass(mod);
|
||||||
els.healthEl.textContent = healthText(mod);
|
els.healthEl.textContent = healthText(mod);
|
||||||
els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : '');
|
els.healthEl.className = 'module-status ' + healthClass(mod);
|
||||||
els.mountError.hidden = !mod.mountError;
|
if (!mod.incompatible) {
|
||||||
els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
|
els.mountError.hidden = !mod.mountError;
|
||||||
|
els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
|
||||||
|
}
|
||||||
if (document.activeElement !== els.toggle) {
|
if (document.activeElement !== els.toggle) {
|
||||||
els.toggle.checked = mod.enabled;
|
els.toggle.checked = mod.enabled;
|
||||||
els.toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled';
|
els.toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled';
|
||||||
@@ -272,7 +681,7 @@ async function refreshStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── named layouts ──────────────────────────────────────────────────── */
|
/* ── saved layouts ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
async function loadLayouts() {
|
async function loadLayouts() {
|
||||||
let body;
|
let body;
|
||||||
@@ -288,7 +697,7 @@ async function loadLayouts() {
|
|||||||
if (!layouts.length) {
|
if (!layouts.length) {
|
||||||
const none = document.createElement('div');
|
const none = document.createElement('div');
|
||||||
none.className = 'layouts-empty';
|
none.className = 'layouts-empty';
|
||||||
none.textContent = 'No named layouts saved yet. Save one from the dashboard’s Layout menu.';
|
none.textContent = 'No layouts saved yet. Save one from the dashboard’s Layout menu.';
|
||||||
layoutsList.appendChild(none);
|
layoutsList.appendChild(none);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -356,14 +765,332 @@ async function loadState() {
|
|||||||
}
|
}
|
||||||
loginCard.hidden = true;
|
loginCard.hidden = true;
|
||||||
modulesSection.hidden = false;
|
modulesSection.hidden = false;
|
||||||
|
availableSection.hidden = false;
|
||||||
|
shellVersion = String(state.version || '');
|
||||||
|
shellVersionEl.textContent = shellVersion ? 'v' + shellVersion : '';
|
||||||
|
const storageNote = document.getElementById('storage-note');
|
||||||
|
if (state.dataDir) {
|
||||||
|
document.getElementById('storage-path').textContent = state.dataDir;
|
||||||
|
storageNote.hidden = false;
|
||||||
|
}
|
||||||
modulesList.innerHTML = '';
|
modulesList.innerHTML = '';
|
||||||
liveEls.clear();
|
liveEls.clear();
|
||||||
for (const mod of state.modules || []) {
|
for (const mod of state.modules || []) {
|
||||||
modulesList.appendChild(buildModuleCard(mod));
|
modulesList.appendChild(buildModuleCard(mod));
|
||||||
}
|
}
|
||||||
|
loadTheme();
|
||||||
loadLayouts();
|
loadLayouts();
|
||||||
|
loadCatalog(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── theme ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* The dashboard palette is a server-wide choice. The server lists the
|
||||||
|
presets (GET /api/theme — public, dashboards need it too) and the admin
|
||||||
|
page shows them as swatches; picking one POSTs it, applies it here at
|
||||||
|
once, and the server's `theme` shell event switches every open dashboard
|
||||||
|
— and any other admin page — live. The palettes themselves are
|
||||||
|
html[data-theme="…"] variable sets in the shell's style.css, which this
|
||||||
|
page shares, so the admin page follows its own choice. */
|
||||||
|
|
||||||
|
const LS_THEME = 'proddash:theme';
|
||||||
|
let themeList = [];
|
||||||
|
let currentTheme = '';
|
||||||
|
|
||||||
|
function applyTheme(id) {
|
||||||
|
const theme = String(id || '');
|
||||||
|
if (!/^[a-z][a-z0-9-]{0,31}$/.test(theme)) return;
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
try { localStorage.setItem(LS_THEME, theme); } catch { /* private mode — fine */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The last-known theme first (shared with the dashboard, same origin), so
|
||||||
|
// the page doesn't flash the default palette before the server answers.
|
||||||
|
try { applyTheme(localStorage.getItem(LS_THEME)); } catch { /* fine */ }
|
||||||
|
|
||||||
|
function themeName(id) {
|
||||||
|
return themeList.find((t) => t.id === id)?.name || id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderThemes() {
|
||||||
|
themeSwatches.innerHTML = '';
|
||||||
|
for (const t of themeList) {
|
||||||
|
const current = t.id === currentTheme;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'theme-swatch' + (current ? ' is-current' : '');
|
||||||
|
btn.setAttribute('role', 'radio');
|
||||||
|
btn.setAttribute('aria-checked', current ? 'true' : 'false');
|
||||||
|
btn.title = current ? `${t.name} — the current theme` : `Switch every dashboard to ${t.name}`;
|
||||||
|
const preview = document.createElement('span');
|
||||||
|
preview.className = 'theme-preview';
|
||||||
|
preview.style.setProperty('--sw-bg', String(t.colors?.bg || '#000'));
|
||||||
|
preview.style.setProperty('--sw-panel', String(t.colors?.panel || '#222'));
|
||||||
|
preview.style.setProperty('--sw-accent', String(t.colors?.accent || '#fff'));
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'theme-name';
|
||||||
|
name.textContent = t.name;
|
||||||
|
btn.append(preview, name);
|
||||||
|
btn.addEventListener('click', () => chooseTheme(t.id));
|
||||||
|
themeSwatches.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the server's theme and list. Applied to this page either way (the
|
||||||
|
* endpoint is public, so even the login card wears the right palette); the
|
||||||
|
* swatch section itself is only revealed for an unlocked page.
|
||||||
|
*/
|
||||||
|
async function loadTheme(reveal = true) {
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await api('/api/theme');
|
||||||
|
} catch {
|
||||||
|
if (reveal) themeSection.hidden = true; // a server without themes — nothing to offer
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
themeList = Array.isArray(body.themes) ? body.themes.filter((t) => t && t.id && t.name) : [];
|
||||||
|
currentTheme = String(body.theme || '');
|
||||||
|
applyTheme(currentTheme);
|
||||||
|
renderThemes();
|
||||||
|
if (reveal) themeSection.hidden = !themeList.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTheme(false);
|
||||||
|
|
||||||
|
async function chooseTheme(id) {
|
||||||
|
if (id === currentTheme) return;
|
||||||
|
const previous = currentTheme;
|
||||||
|
currentTheme = id;
|
||||||
|
applyTheme(id); // at once, here — the server's event confirms it everywhere else
|
||||||
|
renderThemes();
|
||||||
|
themeStatus.textContent = '';
|
||||||
|
themeStatus.className = 'admin-status';
|
||||||
|
try {
|
||||||
|
await api('/api/admin/theme', { method: 'POST', body: JSON.stringify({ theme: id }) });
|
||||||
|
themeStatus.textContent = `${themeName(id)} — every open dashboard switched.`;
|
||||||
|
themeStatus.className = 'admin-status ok';
|
||||||
|
} catch (e) {
|
||||||
|
currentTheme = previous;
|
||||||
|
applyTheme(previous);
|
||||||
|
renderThemes();
|
||||||
|
themeStatus.textContent = e.message;
|
||||||
|
themeStatus.className = 'admin-status error';
|
||||||
|
if (e.authRequired) showLogin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow a theme change made elsewhere (another admin page) live.
|
||||||
|
let themeEvents = null;
|
||||||
|
let themeEventsRetry = null;
|
||||||
|
|
||||||
|
function subscribeThemeEvents() {
|
||||||
|
try { themeEvents?.close(); } catch { /* not open */ }
|
||||||
|
themeEvents = new EventSource('/api/events');
|
||||||
|
themeEvents.addEventListener('theme', (e) => {
|
||||||
|
let theme = '';
|
||||||
|
try { theme = String(JSON.parse(e.data).theme || ''); } catch { return; }
|
||||||
|
if (!theme || theme === currentTheme) return;
|
||||||
|
currentTheme = theme;
|
||||||
|
applyTheme(theme);
|
||||||
|
renderThemes();
|
||||||
|
});
|
||||||
|
themeEvents.onerror = () => {
|
||||||
|
if (themeEvents.readyState === EventSource.CLOSED) {
|
||||||
|
clearTimeout(themeEventsRetry);
|
||||||
|
themeEventsRetry = setTimeout(subscribeThemeEvents, 4000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeThemeEvents();
|
||||||
|
|
||||||
|
/* ── the repo: available modules, module updates, shell update ──────── */
|
||||||
|
|
||||||
|
function fmtTime(ms) {
|
||||||
|
return ms ? new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCatalog(refresh) {
|
||||||
|
catalogStatus.className = 'admin-status';
|
||||||
|
catalogStatus.textContent = refresh ? 'Checking the repo…' : 'Checking…';
|
||||||
|
let cat;
|
||||||
|
try {
|
||||||
|
cat = await api('/api/admin/catalog' + (refresh ? '?refresh=1' : ''));
|
||||||
|
} catch (e) {
|
||||||
|
if (e.authRequired) return showLogin();
|
||||||
|
catalogStatus.className = 'admin-status error';
|
||||||
|
catalogStatus.textContent = e.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderShell(cat.shell || {});
|
||||||
|
renderAvailable(cat);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShell(shell) {
|
||||||
|
const remote = shell.remote || {};
|
||||||
|
if (remote.error) {
|
||||||
|
catalogStatus.className = 'admin-status error';
|
||||||
|
catalogStatus.textContent = `Couldn't reach ${shell.repo}: ${remote.error}`;
|
||||||
|
} else {
|
||||||
|
catalogStatus.className = 'admin-status';
|
||||||
|
catalogStatus.textContent = `${shell.repo}@${shell.branch}` +
|
||||||
|
(remote.version ? ` · latest ProdDash ${remote.version}` : '') +
|
||||||
|
(remote.checkedAt ? ` · checked ${fmtTime(remote.checkedAt)}` : '');
|
||||||
|
}
|
||||||
|
if (!shell.updateAvailable) {
|
||||||
|
updateCard.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateCard.hidden = false;
|
||||||
|
updateTitle.textContent = shell.versionBehind
|
||||||
|
? `ProdDash ${remote.version} is available`
|
||||||
|
: `New commits on ${shell.branch}`;
|
||||||
|
const where = shell.local?.isGit
|
||||||
|
? `a git checkout of ${shell.local.branch} at ${String(shell.local.sha || '').slice(0, 7)}; updating runs git pull`
|
||||||
|
: 'not a git checkout; updating unpacks the repo archive over the app folder';
|
||||||
|
updateCopy.textContent = `You're running ProdDash ${shell.version} (${where}). Settings and layouts live outside the app folder and are not touched.`;
|
||||||
|
updateChanges.href = shell.changesUrl || '#';
|
||||||
|
updateBtn.disabled = Boolean(shell.updateBlocker) || updateBtn.dataset.busy === '1';
|
||||||
|
if (shell.updateBlocker) {
|
||||||
|
updateStatus.className = 'admin-status error';
|
||||||
|
updateStatus.textContent = `Can't update from here: ${shell.updateBlocker}.`;
|
||||||
|
} else if (updateBtn.dataset.busy !== '1') {
|
||||||
|
updateStatus.className = 'admin-status';
|
||||||
|
updateStatus.textContent = 'Updating restarts ProdDash; open dashboards reconnect on their own.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAvailable(cat) {
|
||||||
|
availableList.innerHTML = '';
|
||||||
|
const updates = cat.updates || [];
|
||||||
|
// newer versions of installed modules → a button on their card
|
||||||
|
for (const els of liveEls.values()) els.updateSlot.innerHTML = '';
|
||||||
|
for (const up of updates) {
|
||||||
|
const els = liveEls.get(up.id);
|
||||||
|
if (!els) continue;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'btn small primary';
|
||||||
|
btn.textContent = `Update to v${up.version}`;
|
||||||
|
btn.disabled = !up.compatible;
|
||||||
|
btn.title = up.compatible ? `Download v${up.version} from the repo` : up.reason;
|
||||||
|
btn.addEventListener('click', () => installFromCatalog(up, btn));
|
||||||
|
els.updateSlot.appendChild(btn);
|
||||||
|
}
|
||||||
|
const available = cat.available || [];
|
||||||
|
if (!available.length) {
|
||||||
|
const none = document.createElement('div');
|
||||||
|
none.className = 'layouts-empty';
|
||||||
|
none.textContent = cat.shell?.remote?.error
|
||||||
|
? 'The repo could not be reached, and nothing bundled is waiting to be installed.'
|
||||||
|
: 'Everything the repo offers is installed.';
|
||||||
|
availableList.appendChild(none);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const item of available) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'catalog-row';
|
||||||
|
const main = document.createElement('div');
|
||||||
|
main.className = 'catalog-main';
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'catalog-title';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'module-name';
|
||||||
|
name.textContent = item.name;
|
||||||
|
const version = document.createElement('span');
|
||||||
|
version.className = 'module-version';
|
||||||
|
version.textContent = item.version ? 'v' + item.version : '';
|
||||||
|
const source = document.createElement('span');
|
||||||
|
source.className = 'module-source';
|
||||||
|
source.textContent = item.source === 'bundled' ? 'bundled' : 'repo';
|
||||||
|
source.title = item.source === 'bundled' ? 'Ships with this copy of ProdDash — restoring it downloads nothing' : 'Downloaded from the repo';
|
||||||
|
title.append(name, version, source);
|
||||||
|
const desc = document.createElement('div');
|
||||||
|
desc.className = 'module-desc';
|
||||||
|
desc.textContent = item.description || '';
|
||||||
|
const req = document.createElement('div');
|
||||||
|
req.className = 'module-health' + (item.compatible ? '' : ' error');
|
||||||
|
req.textContent = item.compatible
|
||||||
|
? (item.requires ? `Requires ProdDash ${item.requires}` : '')
|
||||||
|
: item.reason;
|
||||||
|
main.append(title, desc, req);
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'btn small primary';
|
||||||
|
btn.textContent = 'Install';
|
||||||
|
btn.disabled = !item.compatible;
|
||||||
|
btn.title = item.compatible ? '' : item.reason;
|
||||||
|
btn.addEventListener('click', () => installFromCatalog(item, btn));
|
||||||
|
row.append(main, btn);
|
||||||
|
availableList.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installFromCatalog(item, btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
const label = btn.textContent;
|
||||||
|
btn.textContent = 'Installing…';
|
||||||
|
try {
|
||||||
|
const r = await api(`/api/admin/modules/${encodeURIComponent(item.id)}/install`, { method: 'POST', body: '{}' });
|
||||||
|
toast(`${item.name} ${r.downloaded ? 'installed' : 'restored'}${r.version ? ' (v' + r.version + ')' : ''}`);
|
||||||
|
await loadState();
|
||||||
|
} catch (e) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = label;
|
||||||
|
if (e.authRequired) showLogin();
|
||||||
|
else toast(e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('catalog-refresh').addEventListener('click', () => loadCatalog(true));
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** After the update the server exits and comes back; wait for it. */
|
||||||
|
async function waitForRestart(before) {
|
||||||
|
const started = Date.now();
|
||||||
|
let sawDown = false;
|
||||||
|
while (Date.now() - started < 90000) {
|
||||||
|
await sleep(1500);
|
||||||
|
try {
|
||||||
|
const s = await api('/api/admin/state');
|
||||||
|
if (sawDown || s.version !== before) {
|
||||||
|
toast(`ProdDash ${s.version} is running`);
|
||||||
|
await sleep(600);
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 401) { location.reload(); return; } // back up, passcode wanted again
|
||||||
|
sawDown = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateStatus.className = 'admin-status error';
|
||||||
|
updateStatus.textContent = 'ProdDash did not come back on its own — start it again on the server, then reload this page.';
|
||||||
|
updateBtn.dataset.busy = '';
|
||||||
|
updateBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBtn.addEventListener('click', async () => {
|
||||||
|
if (!window.confirm('Update ProdDash now? The server restarts and every open dashboard reconnects a few seconds later.')) return;
|
||||||
|
updateBtn.dataset.busy = '1';
|
||||||
|
updateBtn.disabled = true;
|
||||||
|
updateStatus.className = 'admin-status';
|
||||||
|
updateStatus.textContent = 'Updating…';
|
||||||
|
const before = shellVersion;
|
||||||
|
try {
|
||||||
|
const r = await api('/api/admin/update', { method: 'POST', body: '{}' });
|
||||||
|
updateStatus.textContent = `Updated ${r.from} → ${r.to || 'latest commit'} via ${r.method}. Restarting…`;
|
||||||
|
} catch (e) {
|
||||||
|
updateStatus.className = 'admin-status error';
|
||||||
|
updateStatus.textContent = e.message;
|
||||||
|
updateBtn.dataset.busy = '';
|
||||||
|
updateBtn.disabled = false;
|
||||||
|
if (e.authRequired) showLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await waitForRestart(before);
|
||||||
|
});
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (modulesSection.hidden) return;
|
if (modulesSection.hidden) return;
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
|
|||||||
+31
-7
@@ -11,7 +11,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header id="topbar">
|
<header id="topbar">
|
||||||
<span id="brand">Prod<span class="accent">Dash</span> <span class="admin-tag">Admin</span></span>
|
<span id="brand">Prod<span class="accent">Dash</span> <span class="admin-tag">Admin</span> <span class="admin-version" id="shell-version"></span></span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<a class="tb-btn" href="/">← Dashboard</a>
|
<a class="tb-btn" href="/">← Dashboard</a>
|
||||||
</header>
|
</header>
|
||||||
@@ -27,18 +27,42 @@
|
|||||||
<div class="admin-status" id="login-status"></div>
|
<div class="admin-status" id="login-status"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-card update-card" id="update-card" hidden>
|
||||||
|
<div class="update-head">
|
||||||
|
<span class="dot connecting"></span>
|
||||||
|
<strong id="update-title">Update available</strong>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<a class="btn small" id="update-changes" href="#" target="_blank" rel="noopener">What changed</a>
|
||||||
|
<button class="btn small primary" id="update-btn">Update now</button>
|
||||||
|
</div>
|
||||||
|
<p class="admin-copy" id="update-copy"></p>
|
||||||
|
<div class="admin-status" id="update-status"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="theme-section" hidden>
|
||||||
|
<h1 class="admin-h1">Theme</h1>
|
||||||
|
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
|
||||||
|
<div class="admin-status" id="theme-status"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="modules-section" hidden>
|
<section id="modules-section" hidden>
|
||||||
<h1 class="admin-h1">Modules</h1>
|
<h1 class="admin-h1">Modules</h1>
|
||||||
<p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the
|
<p class="admin-copy admin-storage" id="storage-note" hidden>Saved on the server at
|
||||||
module immediately — open tiles reconnect on their own. Per-tile options (filters, text size)
|
<code id="storage-path"></code> — outside the app folder, so updating ProdDash keeps them.</p>
|
||||||
live in each tile's gear menu instead.</p>
|
|
||||||
<div id="modules-list"></div>
|
<div id="modules-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="available-section" hidden>
|
||||||
|
<h1 class="admin-h1">Available modules</h1>
|
||||||
|
<div class="catalog-toolbar">
|
||||||
|
<button class="btn small" id="catalog-refresh">Check again</button>
|
||||||
|
<span class="admin-status" id="catalog-status"></span>
|
||||||
|
</div>
|
||||||
|
<div id="available-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="layouts-section" hidden>
|
<section id="layouts-section" hidden>
|
||||||
<h1 class="admin-h1">Named layouts</h1>
|
<h1 class="admin-h1">Saved layouts</h1>
|
||||||
<p class="admin-copy">Layouts saved to the server from the dashboard's Layout menu. Loading one
|
|
||||||
copies it into that browser; deleting one here does not touch any browser's current layout.</p>
|
|
||||||
<div id="layouts-list"></div>
|
<div id="layouts-list"></div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+10
-1
@@ -19,6 +19,13 @@
|
|||||||
|
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
|
||||||
|
<button class="tb-btn icon" id="fs-btn" title="Fullscreen — auto-fits the layout to the screen" aria-label="Fullscreen">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M4 9V5a1 1 0 0 1 1-1h4"/><path d="M20 9V5a1 1 0 0 0-1-1h-4"/>
|
||||||
|
<path d="M4 15v4a1 1 0 0 0 1 1h4"/><path d="M20 15v4a1 1 0 0 1-1 1h-4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="dropdown" id="add-dd">
|
<div class="dropdown" id="add-dd">
|
||||||
<button class="tb-btn icon" id="add-btn" title="Add a module tile" aria-label="Add tile">+</button>
|
<button class="tb-btn icon" id="add-btn" title="Add a module tile" aria-label="Add tile">+</button>
|
||||||
<div class="menu" id="add-menu" hidden></div>
|
<div class="menu" id="add-menu" hidden></div>
|
||||||
@@ -35,7 +42,7 @@
|
|||||||
<div class="menu" id="layout-menu" hidden></div>
|
<div class="menu" id="layout-menu" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a class="tb-btn" href="/admin/" title="Configure modules (server-wide)">
|
<a class="tb-btn" id="admin-link" href="/admin/" title="Configure modules (server-wide)">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm8.4-2.1.1-1.4-.1-1.4 1.7-1.3-1.6-2.8-2 .7a7.9 7.9 0 0 0-2.4-1.4l-.3-2.1H11l-.3 2.1a7.9 7.9 0 0 0-2.4 1.4l-2-.7L4.7 9.3 6.4 10.6l-.1 1.4.1 1.4-1.7 1.3 1.6 2.8 2-.7a7.9 7.9 0 0 0 2.4 1.4l.3 2.1h2.8l.3-2.1a7.9 7.9 0 0 0 2.4-1.4l2 .7 1.6-2.8-1.7-1.3Z"/>
|
<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm8.4-2.1.1-1.4-.1-1.4 1.7-1.3-1.6-2.8-2 .7a7.9 7.9 0 0 0-2.4-1.4l-.3-2.1H11l-.3 2.1a7.9 7.9 0 0 0-2.4 1.4l-2-.7L4.7 9.3 6.4 10.6l-.1 1.4.1 1.4-1.7 1.3 1.6 2.8 2-.7a7.9 7.9 0 0 0 2.4 1.4l.3 2.1h2.8l.3-2.1a7.9 7.9 0 0 0 2.4-1.4l2 .7 1.6-2.8-1.7-1.3Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -43,6 +50,8 @@
|
|||||||
</a>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar"></button>
|
||||||
|
|
||||||
<main id="gridwrap">
|
<main id="gridwrap">
|
||||||
<div id="grid">
|
<div id="grid">
|
||||||
<div id="empty-state">No tiles yet — use “+ Add tile” to build this dashboard.<br>
|
<div id="empty-state">No tiles yet — use “+ Add tile” to build this dashboard.<br>
|
||||||
|
|||||||
+756
-130
File diff suppressed because it is too large
Load Diff
+341
-16
@@ -10,6 +10,7 @@
|
|||||||
--text: #e6edf3;
|
--text: #e6edf3;
|
||||||
--muted: #8b98a5;
|
--muted: #8b98a5;
|
||||||
--accent: #2ee59a;
|
--accent: #2ee59a;
|
||||||
|
--accent-text: #06281a; /* text that sits on --accent (buttons, badges) */
|
||||||
--danger: #f4433c;
|
--danger: #f4433c;
|
||||||
--warn: #f0b429;
|
--warn: #f0b429;
|
||||||
/* grid geometry (shell.js reads these) */
|
/* grid geometry (shell.js reads these) */
|
||||||
@@ -39,12 +40,58 @@ button { font-family: inherit; }
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 12px;
|
height: 46px;
|
||||||
|
padding: 0 12px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
|
transition: height 0.22s ease, border-bottom-width 0.22s ease;
|
||||||
}
|
}
|
||||||
|
/* collapsed by its notch (and automatically in fullscreen) */
|
||||||
|
body.menu-hidden #topbar {
|
||||||
|
height: 0;
|
||||||
|
border-bottom-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The bar's show/hide handle: a small accent lip on its bottom edge, the
|
||||||
|
same idea as a tile's notch. It sits at the left — never screen-centre,
|
||||||
|
where a tile's own (centred) tab lands once both bars are hidden — and
|
||||||
|
grows under the pointer so it is easy to hit without being loud at rest.
|
||||||
|
It used to be the ProdDash wordmark on a pill hanging past the bar, which
|
||||||
|
read as a step cut out of the bar, and animating its font-size on the way
|
||||||
|
out was the jerkiest thing on the page in Safari. Everything here moves by
|
||||||
|
transform, opacity and its own fixed size: no text metrics, no reflow. */
|
||||||
|
#topbar-notch {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 10px;
|
||||||
|
width: 28px;
|
||||||
|
height: 5px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0 0 5px 5px;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 60;
|
||||||
|
/* Glued to the bar's bottom edge: --topbar-h is the bar's live height, so
|
||||||
|
the lip rides the bar's own collapse instead of running a second,
|
||||||
|
competing animation against it. */
|
||||||
|
transform: translateY(var(--topbar-h, 46px));
|
||||||
|
transition: width 0.16s ease, height 0.16s ease,
|
||||||
|
opacity 0.16s ease, border-radius 0.16s ease;
|
||||||
|
}
|
||||||
|
#topbar-notch:hover,
|
||||||
|
#topbar-notch:focus-visible {
|
||||||
|
width: 60px;
|
||||||
|
height: 10px;
|
||||||
|
opacity: 1;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
}
|
||||||
|
/* With the bar collapsed --topbar-h is 0, so the lip ends up at the very top
|
||||||
|
edge without a rule of its own. */
|
||||||
|
|
||||||
#brand {
|
#brand {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -207,9 +254,15 @@ button { font-family: inherit; }
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
transition: height 0.22s ease, border-bottom-width 0.22s ease;
|
||||||
}
|
}
|
||||||
.tile.dragging .tile-head { cursor: grabbing; }
|
.tile.dragging .tile-head { cursor: grabbing; }
|
||||||
.tile.head-hidden .tile-head { display: none; }
|
/* hidden = collapsed, not removed, so the notch toggle animates */
|
||||||
|
.tile.head-hidden .tile-head {
|
||||||
|
height: 0;
|
||||||
|
border-bottom-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.tile-head .dot {
|
.tile-head .dot {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -305,7 +358,7 @@ button { font-family: inherit; }
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 6;
|
z-index: 6;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s ease, top 0.15s ease;
|
transition: opacity 0.15s ease, top 0.22s ease;
|
||||||
}
|
}
|
||||||
.tile:hover .tile-notch { opacity: 0.6; }
|
.tile:hover .tile-notch { opacity: 0.6; }
|
||||||
.tile .tile-notch:hover { opacity: 1; }
|
.tile .tile-notch:hover { opacity: 1; }
|
||||||
@@ -321,6 +374,22 @@ button { font-family: inherit; }
|
|||||||
}
|
}
|
||||||
.tile-notch:hover::after { background: var(--text); }
|
.tile-notch:hover::after { background: var(--text); }
|
||||||
|
|
||||||
|
/* Fullscreen is for watching, not arranging: every title bar folds away, and
|
||||||
|
a tile's own hidden/shown preference is left untouched for when the screen
|
||||||
|
comes back. The notch still peeks one open, and it folds back once the
|
||||||
|
pointer has been off that tile for a while (PEEK_HIDE_MS in shell.js). */
|
||||||
|
body.fullscreen .tile:not(.head-peek) .tile-head {
|
||||||
|
height: 0;
|
||||||
|
border-bottom-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
body.fullscreen .tile:not(.head-peek) .tile-notch { top: 0; }
|
||||||
|
body.fullscreen .tile.head-peek .tile-head {
|
||||||
|
height: 32px;
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
}
|
||||||
|
body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
|
||||||
|
|
||||||
.tile-body {
|
.tile-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -329,30 +398,74 @@ button { font-family: inherit; }
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-resize {
|
.tile-resize,
|
||||||
|
.tile-resize-left {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
cursor: nwse-resize;
|
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
opacity: 0.6;
|
opacity: 0; /* corner indicators only show while hovering the tile */
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
}
|
}
|
||||||
.tile-resize:hover { opacity: 1; color: var(--text); }
|
.tile-resize { right: 0; cursor: nwse-resize; }
|
||||||
.tile-resize::after {
|
.tile-resize-left { left: 0; cursor: nesw-resize; }
|
||||||
|
.tile:hover .tile-resize,
|
||||||
|
.tile:hover .tile-resize-left { opacity: 0.6; }
|
||||||
|
.tile .tile-resize:hover,
|
||||||
|
.tile .tile-resize-left:hover { opacity: 1; color: var(--text); }
|
||||||
|
.tile-resize::after,
|
||||||
|
.tile-resize-left::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 4px;
|
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
width: 9px;
|
width: 9px;
|
||||||
height: 9px;
|
height: 9px;
|
||||||
border-right: 2px solid currentColor;
|
|
||||||
border-bottom: 2px solid currentColor;
|
border-bottom: 2px solid currentColor;
|
||||||
|
}
|
||||||
|
.tile-resize::after {
|
||||||
|
right: 4px;
|
||||||
|
border-right: 2px solid currentColor;
|
||||||
border-radius: 0 0 3px 0;
|
border-radius: 0 0 3px 0;
|
||||||
}
|
}
|
||||||
|
.tile-resize-left::after {
|
||||||
|
left: 4px;
|
||||||
|
border-left: 2px solid currentColor;
|
||||||
|
border-radius: 0 0 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* bottom edge: drag to move the tile's bottom edge (height only). Drawn as
|
||||||
|
the same 2px line as the corner handles it sits between, on the same
|
||||||
|
4px inset, so the three read as one set of marks along the bottom. */
|
||||||
|
.tile-resize-bottom {
|
||||||
|
position: absolute;
|
||||||
|
left: 24px;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 0;
|
||||||
|
height: 10px;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: ns-resize;
|
||||||
|
touch-action: none;
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.tile-resize-bottom::after {
|
||||||
|
content: "";
|
||||||
|
width: 44px;
|
||||||
|
max-width: 60%;
|
||||||
|
height: 0;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-bottom: 2px solid currentColor;
|
||||||
|
}
|
||||||
|
.tile:hover .tile-resize-bottom { opacity: 0.6; }
|
||||||
|
.tile .tile-resize-bottom:hover,
|
||||||
|
.tile.resizing .tile-resize-bottom { opacity: 1; color: var(--text); }
|
||||||
|
|
||||||
/* ── seam divider between adjacent tiles ───────────────────────────── */
|
/* ── seam divider between adjacent tiles ───────────────────────────── */
|
||||||
|
|
||||||
@@ -382,13 +495,26 @@ button { font-family: inherit; }
|
|||||||
transition: opacity 0.15s ease, background 0.15s ease;
|
transition: opacity 0.15s ease, background 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-divider:hover::after { opacity: 0.7; }
|
.tile-divider:hover::after,
|
||||||
|
.tile-divider.linked::after { opacity: 0.7; }
|
||||||
.tile-divider.active::after { opacity: 1; background: var(--text); }
|
.tile-divider.active::after { opacity: 1; background: var(--text); }
|
||||||
|
|
||||||
/* Hide the pills while a tile itself is being moved or corner-resized —
|
/* Hide the pills while a tile itself is being moved or corner-resized —
|
||||||
they're rebuilt for the new geometry on release. */
|
they're rebuilt for the new geometry on release. */
|
||||||
#grid.no-dividers .tile-divider { display: none; }
|
#grid.no-dividers .tile-divider { display: none; }
|
||||||
|
|
||||||
|
/* ── fullscreen mode ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Fullscreen is a viewing mode: the layout auto-fits the screen, so the
|
||||||
|
editing affordances (corner handles, seam pills) are parked. */
|
||||||
|
body.fullscreen .tile-resize,
|
||||||
|
body.fullscreen .tile-resize-left,
|
||||||
|
body.fullscreen .tile-resize-bottom,
|
||||||
|
body.fullscreen .tile-divider,
|
||||||
|
body.fullscreen #admin-link { display: none; }
|
||||||
|
body.fullscreen #grid { padding-bottom: 0; }
|
||||||
|
#gridwrap.no-scroll { overflow-y: hidden; }
|
||||||
|
|
||||||
/* status message a module can show while degraded (also used by the shell
|
/* status message a module can show while degraded (also used by the shell
|
||||||
when a module fails to load) */
|
when a module fails to load) */
|
||||||
.tile-msg {
|
.tile-msg {
|
||||||
@@ -423,6 +549,73 @@ button { font-family: inherit; }
|
|||||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A popover with grouped settings needs room for its columns; it still
|
||||||
|
never grows past the tile (max-width above). */
|
||||||
|
.tile-settings.has-groups {
|
||||||
|
min-width: min(540px, calc(100% - 12px));
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
.tile-settings .field-group {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.tile-settings .field-group-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.tile-settings details.field-group > summary.field-group-title {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.tile-settings details.field-group > summary::-webkit-details-marker { display: none; }
|
||||||
|
.tile-settings details.field-group > summary.field-group-title::before {
|
||||||
|
content: "";
|
||||||
|
width: 0; height: 0;
|
||||||
|
border-left: 5px solid var(--muted);
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
border-bottom: 4px solid transparent;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.tile-settings details.field-group[open] > summary.field-group-title::before { transform: rotate(90deg); }
|
||||||
|
.tile-settings details.field-group[open] > summary.field-group-title { margin-bottom: 8px; }
|
||||||
|
.tile-settings .field-group .field:last-child { margin-bottom: 0; }
|
||||||
|
/* Settings apply as they change, so nothing sits under the last field — its
|
||||||
|
bottom margin would only pad the popover's own padding a second time. */
|
||||||
|
.tile-settings > :last-child { margin-bottom: 0; }
|
||||||
|
.tile-settings .field-group-body.is-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
|
||||||
|
gap: 6px 14px; /* the grid gap is the whole row spacing: no per-field margin */
|
||||||
|
}
|
||||||
|
.tile-settings .field-group-body.is-columns .field { margin-bottom: 0; }
|
||||||
|
.tile-settings .field-group-body.is-columns .field-group-help { grid-column: 1 / -1; }
|
||||||
|
@container (max-width: 420px) {
|
||||||
|
.tile-settings .field-group-body.is-columns { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
@container (max-width: 260px) {
|
||||||
|
.tile-settings .field-group-body.is-columns { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.tile-settings .field-group-help,
|
||||||
|
.tile-settings .field-help {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.tile-settings .field-group-help { margin: -2px 0 6px; }
|
||||||
|
.tile-settings .field-help { margin-top: 1px; }
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -447,14 +640,85 @@ button { font-family: inherit; }
|
|||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
/* boolean / switch settings: label on the left, switch on the right (help,
|
||||||
|
when there is any, wraps under both) */
|
||||||
.field.check {
|
.field.check {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: space-between;
|
||||||
|
gap: 4px 10px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.field.check input { width: 15px; height: 15px; accent-color: var(--accent); }
|
.field.check .check-label { flex: 1 1 0; min-width: 0; }
|
||||||
|
.field.check .field-help { flex-basis: 100%; }
|
||||||
|
|
||||||
|
/* The switch: the admin page's module switch, same track, knob and colours,
|
||||||
|
so a toggle reads the same on both surfaces. On = shown/enabled (accent),
|
||||||
|
off = hidden/disabled (border track, muted knob). The real checkbox lies
|
||||||
|
invisibly over the track — still focusable, so Tab reaches it and Space
|
||||||
|
flips it — and the label around it makes the text clickable too. */
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.switch input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.switch .track {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
width: 34px;
|
||||||
|
height: 19px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--border);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.switch .track::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
transition: transform 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
.switch input:checked + .track { background: var(--accent); }
|
||||||
|
.switch input:checked + .track::after { transform: translateX(15px); background: var(--bg); }
|
||||||
|
.switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* color fields: label on the left, swatch on the right */
|
||||||
|
.field.color-field {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.field.color-field > span { flex: 1; min-width: 0; }
|
||||||
|
.field.color-field input[type="color"] {
|
||||||
|
width: 44px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.field.color-field input[type="color"]:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.field.color-field .field-help { flex-basis: 100%; }
|
||||||
|
|
||||||
/* ── modal dialogs (layout name prompt etc.) ───────────────────────── */
|
/* ── modal dialogs (layout name prompt etc.) ───────────────────────── */
|
||||||
|
|
||||||
@@ -499,7 +763,7 @@ button { font-family: inherit; }
|
|||||||
.btn.primary {
|
.btn.primary {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: #06281a;
|
color: var(--accent-text);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,5 +808,66 @@ button { font-family: inherit; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
#topbar { padding: 6px 8px; flex-wrap: wrap; }
|
#topbar { padding: 0 8px; gap: 6px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── themes ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Preset palettes, chosen in Admin → Theme and applied by shell.js (and the
|
||||||
|
admin page) as html[data-theme="…"]. Each swaps exactly the eight palette
|
||||||
|
variables the module guide names — nothing else — so every module and
|
||||||
|
every shell element follows, and a colour a tile is configured with (set
|
||||||
|
on the tile itself) still wins. The :root block at the top of this file is
|
||||||
|
"booth", the default; the list of ids and names lives in server.js
|
||||||
|
(GET /api/theme). All four are booth-grade: high contrast, the accent
|
||||||
|
nowhere near the danger red or the warning amber, muted still readable. */
|
||||||
|
|
||||||
|
/* Harbor — deep navy, cyan accent */
|
||||||
|
html[data-theme="harbor"] {
|
||||||
|
--bg: #06101f;
|
||||||
|
--panel: #0c1a30;
|
||||||
|
--border: #1a2b48;
|
||||||
|
--text: #e6eefb;
|
||||||
|
--muted: #8ea4c8;
|
||||||
|
--accent: #2ad4ee;
|
||||||
|
--danger: #ff5a5f;
|
||||||
|
--warn: #f7b53b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Graphite — neutral charcoal, electric-blue accent */
|
||||||
|
html[data-theme="graphite"] {
|
||||||
|
--bg: #0f1012;
|
||||||
|
--panel: #17191d;
|
||||||
|
--border: #2a2d33;
|
||||||
|
--text: #f0f2f4;
|
||||||
|
--muted: #9ca3ad;
|
||||||
|
--accent: #6cb4ff;
|
||||||
|
--danger: #ff4d4f;
|
||||||
|
--warn: #ffb224;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ember — warm broadcast black, orange accent (danger stays a cool red,
|
||||||
|
warn a pale yellow, so the three status colours never blur) */
|
||||||
|
html[data-theme="ember"] {
|
||||||
|
--bg: #120c0a;
|
||||||
|
--panel: #1d1411;
|
||||||
|
--border: #35251e;
|
||||||
|
--text: #f6ede6;
|
||||||
|
--muted: #b39b8c;
|
||||||
|
--accent: #ff8f3a;
|
||||||
|
--danger: #f03247;
|
||||||
|
--warn: #ffd86b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Daylight — the light one, for a front-of-house desk in daytime */
|
||||||
|
html[data-theme="daylight"] {
|
||||||
|
--bg: #eef1f5;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--border: #cfd6e0;
|
||||||
|
--text: #14181f;
|
||||||
|
--muted: #5b6675;
|
||||||
|
--accent: #0b8a5f;
|
||||||
|
--accent-text: #ffffff; /* dark text drowns on this accent */
|
||||||
|
--danger: #d1242f;
|
||||||
|
--warn: #b26a00;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,16 @@
|
|||||||
*
|
*
|
||||||
* Shell server: serves the dashboard client from public/, discovers modules
|
* Shell server: serves the dashboard client from public/, discovers modules
|
||||||
* in modules/, mounts each enabled module's server routes under
|
* in modules/, mounts each enabled module's server routes under
|
||||||
* /api/modules/<id>/, and stores server-side config + named layouts under
|
* /api/modules/<id>/, and stores server-side module config + named layouts
|
||||||
* config/.
|
* in a per-machine data directory (see DATA_DIR below) so that updating the
|
||||||
|
* checkout never loses them.
|
||||||
*
|
*
|
||||||
* Zero dependencies — Node built-ins only (Node 18+ for global fetch).
|
* Zero dependencies — Node built-ins only (Node 18+ for global fetch).
|
||||||
* Configure via config/proddash.json or env vars:
|
* Configure via config/proddash.json (defaults, in the repo), an optional
|
||||||
* PORT (default: value in proddash.json, else 24500)
|
* proddash.json in the data directory (this machine's overrides), or env vars:
|
||||||
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
|
* PORT (default: value in proddash.json, else 24500)
|
||||||
|
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
|
||||||
|
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
|
||||||
*/
|
*/
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
@@ -19,12 +22,60 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const zlib = require('zlib');
|
||||||
|
const { spawn, execFileSync } = require('child_process');
|
||||||
|
|
||||||
const ROOT = __dirname;
|
const ROOT = __dirname;
|
||||||
const PUBLIC_DIR = path.join(ROOT, 'public');
|
const PUBLIC_DIR = path.join(ROOT, 'public');
|
||||||
const CONFIG_DIR = path.join(ROOT, 'config');
|
const BUNDLED_MODULES_DIR = path.join(ROOT, 'modules'); // modules that ship with the checkout
|
||||||
const MODULES_DIR = path.join(ROOT, 'modules');
|
/** Checked-in defaults (proddash.json) — and where runtime state used to live. */
|
||||||
const MODULES_CONFIG_PATH = path.join(CONFIG_DIR, 'modules.json');
|
const REPO_CONFIG_DIR = path.join(ROOT, 'config');
|
||||||
|
|
||||||
|
/* ── data directory ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the app writes — module config (connection settings, API keys,
|
||||||
|
* enabled flags) and named layouts — lives in a per-machine data directory
|
||||||
|
* OUTSIDE the checkout. Pulling, re-cloning or resetting the app folder from
|
||||||
|
* main therefore never touches it, and nothing has to be re-entered in /admin.
|
||||||
|
*
|
||||||
|
* PRODDASH_DATA_DIR overrides the location. A relative path resolves
|
||||||
|
* against the app folder ("config" = the old in-repo spot).
|
||||||
|
* default Windows %APPDATA%\ProdDash
|
||||||
|
* macOS ~/Library/Application Support/ProdDash
|
||||||
|
* other $XDG_CONFIG_HOME/proddash, else ~/.config/proddash
|
||||||
|
*
|
||||||
|
* If the directory cannot be created or written, the server says so and
|
||||||
|
* falls back to the in-repo config/ folder rather than refusing to start.
|
||||||
|
*/
|
||||||
|
function defaultDataDir() {
|
||||||
|
const home = os.homedir();
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash');
|
||||||
|
}
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
return path.join(home, 'Library', 'Application Support', 'ProdDash');
|
||||||
|
}
|
||||||
|
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDataDir() {
|
||||||
|
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
|
||||||
|
const dir = fromEnv ? path.resolve(ROOT, fromEnv) : defaultDataDir();
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.accessSync(dir, fs.constants.W_OK);
|
||||||
|
return dir;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[proddash] cannot use data directory ${dir} (${err.message}) — falling back to ${REPO_CONFIG_DIR}`);
|
||||||
|
return REPO_CONFIG_DIR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATA_DIR = resolveDataDir();
|
||||||
|
const MODULES_CONFIG_PATH = path.join(DATA_DIR, 'modules.json');
|
||||||
|
const LAYOUTS_DIR = path.join(DATA_DIR, 'layouts');
|
||||||
|
const INSTALLED_MODULES_DIR = path.join(DATA_DIR, 'modules'); // modules installed from the admin page
|
||||||
|
|
||||||
/* ── config files ───────────────────────────────────────────────────── */
|
/* ── config files ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -43,9 +94,107 @@ function writeJson(file, value) {
|
|||||||
fs.renameSync(tmp, file); // atomic-ish: never leave a half-written config
|
fs.renameSync(tmp, file); // atomic-ish: never leave a half-written config
|
||||||
}
|
}
|
||||||
|
|
||||||
const shellConfig = readJson(path.join(CONFIG_DIR, 'proddash.json'), {});
|
/**
|
||||||
|
* One-time move-in. Earlier versions kept modules.json and layouts/ inside the
|
||||||
|
* repo's config/ folder; if the data directory has none yet and those files
|
||||||
|
* exist, copy them across (the originals are left alone).
|
||||||
|
*/
|
||||||
|
function migrateLegacyState() {
|
||||||
|
if (DATA_DIR === REPO_CONFIG_DIR) return;
|
||||||
|
const moved = [];
|
||||||
|
const oldModules = path.join(REPO_CONFIG_DIR, 'modules.json');
|
||||||
|
if (!fs.existsSync(MODULES_CONFIG_PATH) && fs.existsSync(oldModules)) {
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(oldModules, MODULES_CONFIG_PATH);
|
||||||
|
moved.push('modules.json');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[proddash] could not copy ${oldModules} into the data directory: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const oldLayouts = path.join(REPO_CONFIG_DIR, 'layouts');
|
||||||
|
let oldFiles = [];
|
||||||
|
try {
|
||||||
|
oldFiles = fs.readdirSync(oldLayouts).filter((f) => f.endsWith('.json'));
|
||||||
|
} catch { /* none to move */ }
|
||||||
|
let haveLayouts = false;
|
||||||
|
try {
|
||||||
|
haveLayouts = fs.readdirSync(LAYOUTS_DIR).some((f) => f.endsWith('.json'));
|
||||||
|
} catch { /* no layouts dir yet */ }
|
||||||
|
if (oldFiles.length && !haveLayouts) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(LAYOUTS_DIR, { recursive: true });
|
||||||
|
for (const f of oldFiles) fs.copyFileSync(path.join(oldLayouts, f), path.join(LAYOUTS_DIR, f));
|
||||||
|
moved.push(`${oldFiles.length} layout${oldFiles.length === 1 ? '' : 's'}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[proddash] could not copy layouts into the data directory: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moved.length) {
|
||||||
|
console.log(`[proddash] moved ${moved.join(' and ')} from ${REPO_CONFIG_DIR} into ${DATA_DIR}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
migrateLegacyState();
|
||||||
|
|
||||||
|
/** Shell settings: checked-in defaults, then this machine's overrides on top. */
|
||||||
|
const shellConfig = {
|
||||||
|
...readJson(path.join(REPO_CONFIG_DIR, 'proddash.json'), {}),
|
||||||
|
...readJson(path.join(DATA_DIR, 'proddash.json'), {}),
|
||||||
|
};
|
||||||
const PORT = Number(process.env.PORT || shellConfig.port || 24500);
|
const PORT = Number(process.env.PORT || shellConfig.port || 24500);
|
||||||
|
|
||||||
|
/* ── shell version & version ranges ─────────────────────────────────── */
|
||||||
|
|
||||||
|
/** The shell's own version, from package.json. Modules declare the range
|
||||||
|
they need in their manifest ("proddash": ">=1.1.0"). */
|
||||||
|
const SHELL_VERSION = String(readJson(path.join(ROOT, 'package.json'), {}).version || '0.0.0');
|
||||||
|
|
||||||
|
function parseVersion(v) {
|
||||||
|
const m = String(v || '').trim().match(/^v?(\d+)\.(\d+)(?:\.(\d+))?/);
|
||||||
|
return m ? [Number(m[1]), Number(m[2]), Number(m[3] || 0)] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** -1 / 0 / 1 like a comparator; unparseable versions sort lowest. */
|
||||||
|
function compareVersions(a, b) {
|
||||||
|
const pa = parseVersion(a) || [-1, 0, 0];
|
||||||
|
const pb = parseVersion(b) || [-1, 0, 0];
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does `version` satisfy `range`? Ranges are deliberately small: "" or "*"
|
||||||
|
* (anything), "1.2.0" (exact), ">=1.2.0", ">1.2.0", "<=…", "<…",
|
||||||
|
* "^1.2.0" (same major, at least that), "~1.2.0" (same minor, at least
|
||||||
|
* that), and space-separated combinations such as ">=1.1.0 <2.0.0".
|
||||||
|
*/
|
||||||
|
function satisfiesVersion(version, range) {
|
||||||
|
const r = String(range || '').trim();
|
||||||
|
if (!r || r === '*') return true;
|
||||||
|
const v = parseVersion(version);
|
||||||
|
if (!v) return false;
|
||||||
|
return r.split(/[\s,]+/).filter(Boolean).every((part) => {
|
||||||
|
const m = part.match(/^(>=|<=|>|<|=|\^|~)?v?(\d+)\.(\d+)(?:\.(\d+))?$/);
|
||||||
|
if (!m) return false;
|
||||||
|
const t = [Number(m[2]), Number(m[3]), Number(m[4] || 0)];
|
||||||
|
const c = compareVersions(v.join('.'), t.join('.'));
|
||||||
|
switch (m[1] || '=') {
|
||||||
|
case '>=': return c >= 0;
|
||||||
|
case '>': return c > 0;
|
||||||
|
case '<=': return c <= 0;
|
||||||
|
case '<': return c < 0;
|
||||||
|
case '^': return v[0] === t[0] && c >= 0;
|
||||||
|
case '~': return v[0] === t[0] && v[1] === t[1] && c >= 0;
|
||||||
|
default: return c === 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirementNote(range) {
|
||||||
|
return `Requires ProdDash ${range} — this is ${SHELL_VERSION}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Per-module server-wide state: { "<id>": { enabled: bool, config: {…} } } */
|
/** Per-module server-wide state: { "<id>": { enabled: bool, config: {…} } } */
|
||||||
let modulesConfig = readJson(MODULES_CONFIG_PATH, {});
|
let modulesConfig = readJson(MODULES_CONFIG_PATH, {});
|
||||||
|
|
||||||
@@ -54,18 +203,19 @@ let modulesConfig = readJson(MODULES_CONFIG_PATH, {});
|
|||||||
/** @type {Map<string, object>} module id -> manifest (with .dir added) */
|
/** @type {Map<string, object>} module id -> manifest (with .dir added) */
|
||||||
const manifests = new Map();
|
const manifests = new Map();
|
||||||
|
|
||||||
function discoverModules() {
|
/** Read every valid manifest in one modules folder. */
|
||||||
manifests.clear();
|
function readManifests(baseDir, source) {
|
||||||
|
const out = [];
|
||||||
let dirs = [];
|
let dirs = [];
|
||||||
try {
|
try {
|
||||||
dirs = fs.readdirSync(MODULES_DIR, { withFileTypes: true })
|
dirs = fs.readdirSync(baseDir, { withFileTypes: true })
|
||||||
.filter((d) => d.isDirectory())
|
.filter((d) => d.isDirectory() && !d.name.startsWith('.'))
|
||||||
.map((d) => d.name);
|
.map((d) => d.name);
|
||||||
} catch {
|
} catch {
|
||||||
return; // no modules/ folder yet — the shell still runs
|
return out; // folder missing — fine
|
||||||
}
|
}
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
const manifestPath = path.join(MODULES_DIR, dir, 'module.json');
|
const manifestPath = path.join(baseDir, dir, 'module.json');
|
||||||
const man = readJson(manifestPath, null);
|
const man = readJson(manifestPath, null);
|
||||||
if (!man || typeof man !== 'object') {
|
if (!man || typeof man !== 'object') {
|
||||||
if (fs.existsSync(manifestPath)) console.warn(`[proddash] ${dir}/module.json is invalid — skipped`);
|
if (fs.existsSync(manifestPath)) console.warn(`[proddash] ${dir}/module.json is invalid — skipped`);
|
||||||
@@ -80,9 +230,48 @@ function discoverModules() {
|
|||||||
console.warn(`[proddash] module "${dir}": manifest has no "client" entry — skipped`);
|
console.warn(`[proddash] module "${dir}": manifest has no "client" entry — skipped`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
man.dir = path.join(MODULES_DIR, dir);
|
man.dir = path.join(baseDir, dir);
|
||||||
manifests.set(dir, man);
|
man.source = source;
|
||||||
|
man.requires = String(man.proddash || '').trim();
|
||||||
|
// A module built for a newer shell is listed (so admin can explain) but never loaded.
|
||||||
|
man.incompatible = satisfiesVersion(SHELL_VERSION, man.requires) ? '' : requirementNote(man.requires);
|
||||||
|
out.push(man);
|
||||||
}
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modules come from two places: the ones bundled with this checkout
|
||||||
|
* (modules/) and the ones installed from the admin page (the data
|
||||||
|
* directory's modules/). Same id in both → the newer version runs; a
|
||||||
|
* downloaded copy the bundled one has since overtaken is removed. A bundled
|
||||||
|
* module the admin page "uninstalled" stays on disk (it belongs to the app
|
||||||
|
* folder) but is left out here.
|
||||||
|
*/
|
||||||
|
function discoverModules() {
|
||||||
|
manifests.clear();
|
||||||
|
const bundled = new Map(readManifests(BUNDLED_MODULES_DIR, 'bundled').map((m) => [m.id, m]));
|
||||||
|
const installed = new Map(readManifests(INSTALLED_MODULES_DIR, 'installed').map((m) => [m.id, m]));
|
||||||
|
for (const [id, man] of bundled) {
|
||||||
|
if (isUninstalled(id)) continue;
|
||||||
|
const copy = installed.get(id);
|
||||||
|
if (copy && compareVersions(copy.version, man.version) >= 0) continue; // the copy wins, below
|
||||||
|
if (copy) {
|
||||||
|
console.log(`[proddash] ${id}: bundled v${man.version} supersedes the installed v${copy.version} — removing that copy`);
|
||||||
|
try { fs.rmSync(copy.dir, { recursive: true, force: true }); } catch { /* it just stays unused */ }
|
||||||
|
installed.delete(id);
|
||||||
|
}
|
||||||
|
manifests.set(id, man);
|
||||||
|
}
|
||||||
|
for (const [id, man] of installed) {
|
||||||
|
if (manifests.has(id)) continue;
|
||||||
|
man.bundledVersion = String(bundled.get(id)?.version || '');
|
||||||
|
manifests.set(id, man);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUninstalled(id) {
|
||||||
|
return moduleState(id).uninstalled === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function moduleState(id) {
|
function moduleState(id) {
|
||||||
@@ -94,6 +283,12 @@ function isEnabled(id) {
|
|||||||
return moduleState(id).enabled !== false; // newly dropped modules default to enabled
|
return moduleState(id).enabled !== false; // newly dropped modules default to enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Enabled and runnable on this shell version — what dashboards may load. */
|
||||||
|
function isActive(id) {
|
||||||
|
const man = manifests.get(id);
|
||||||
|
return Boolean(man) && isEnabled(id) && !man.incompatible;
|
||||||
|
}
|
||||||
|
|
||||||
/** Admin config for a module: schema defaults overlaid with stored values. */
|
/** Admin config for a module: schema defaults overlaid with stored values. */
|
||||||
function effectiveConfig(id) {
|
function effectiveConfig(id) {
|
||||||
const man = manifests.get(id);
|
const man = manifests.get(id);
|
||||||
@@ -167,6 +362,10 @@ function mountModule(id) {
|
|||||||
if (!man) return;
|
if (!man) return;
|
||||||
const entry = { routes: [], handle: null, error: '' };
|
const entry = { routes: [], handle: null, error: '' };
|
||||||
mounted.set(id, entry);
|
mounted.set(id, entry);
|
||||||
|
if (man.incompatible) {
|
||||||
|
entry.error = man.incompatible;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!man.server) return;
|
if (!man.server) return;
|
||||||
const log = makeLog(id);
|
const log = makeLog(id);
|
||||||
try {
|
try {
|
||||||
@@ -174,14 +373,17 @@ function mountModule(id) {
|
|||||||
// Re-require fresh config on every (re)mount; the module code itself stays cached.
|
// Re-require fresh config on every (re)mount; the module code itself stays cached.
|
||||||
const mod = require(serverPath);
|
const mod = require(serverPath);
|
||||||
const config = effectiveConfig(id);
|
const config = effectiveConfig(id);
|
||||||
|
// What a module needs to reach the rest of ProdDash — another module's
|
||||||
|
// routes over loopback, chiefly — without guessing.
|
||||||
|
const shell = { port: PORT };
|
||||||
if (typeof mod.init === 'function') {
|
if (typeof mod.init === 'function') {
|
||||||
entry.handle = mod.init({ config, log }) || null;
|
entry.handle = mod.init({ config, log, shell }) || null;
|
||||||
}
|
}
|
||||||
if (typeof mod.routes === 'function') {
|
if (typeof mod.routes === 'function') {
|
||||||
entry.routes = parseRouteTable(mod.routes({ config, log }));
|
entry.routes = parseRouteTable(mod.routes({ config, log, shell }));
|
||||||
}
|
}
|
||||||
if (typeof mod.tiles === 'function') {
|
if (typeof mod.tiles === 'function') {
|
||||||
entry.tiles = () => mod.tiles({ config, log });
|
entry.tiles = () => mod.tiles({ config, log, shell });
|
||||||
}
|
}
|
||||||
log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : ''));
|
log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : ''));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -309,7 +511,10 @@ async function clientManifest(id) {
|
|||||||
minSize: man.minSize || { w: 1, h: 1 },
|
minSize: man.minSize || { w: 1, h: 1 },
|
||||||
defaultSize: man.defaultSize || { w: 4, h: 3 },
|
defaultSize: man.defaultSize || { w: 4, h: 3 },
|
||||||
instanceSchema: man.instanceSchema || {},
|
instanceSchema: man.instanceSchema || {},
|
||||||
|
instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {},
|
||||||
hasServer: Boolean(man.server),
|
hasServer: Boolean(man.server),
|
||||||
|
// capabilities other modules consume ("timers") — see the module guide
|
||||||
|
provides: Array.isArray(man.provides) ? man.provides.map(String) : [],
|
||||||
config: clientConfig(id),
|
config: clientConfig(id),
|
||||||
tiles: await moduleTiles(id),
|
tiles: await moduleTiles(id),
|
||||||
};
|
};
|
||||||
@@ -341,6 +546,439 @@ setInterval(() => {
|
|||||||
}
|
}
|
||||||
}, 25000).unref();
|
}, 25000).unref();
|
||||||
|
|
||||||
|
/* ── themes ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Preset palettes for the whole dashboard, chosen in Admin → Theme. The
|
||||||
|
variable sets themselves live in public/style.css (html[data-theme="…"]);
|
||||||
|
this list is the one authority on ids and names, and carries the three
|
||||||
|
colours the admin page previews a theme with. The choice is a shell
|
||||||
|
setting — `theme` in the data directory's proddash.json — and reaches open
|
||||||
|
dashboards live as a `theme` shell event. */
|
||||||
|
const THEMES = [
|
||||||
|
{ id: 'booth', name: 'Booth', colors: { bg: '#0b0e12', panel: '#131920', accent: '#2ee59a' } },
|
||||||
|
{ id: 'harbor', name: 'Harbor', colors: { bg: '#06101f', panel: '#0c1a30', accent: '#2ad4ee' } },
|
||||||
|
{ id: 'graphite', name: 'Graphite', colors: { bg: '#0f1012', panel: '#17191d', accent: '#6cb4ff' } },
|
||||||
|
{ id: 'ember', name: 'Ember', colors: { bg: '#120c0a', panel: '#1d1411', accent: '#ff8f3a' } },
|
||||||
|
{ id: 'daylight', name: 'Daylight', colors: { bg: '#eef1f5', panel: '#ffffff', accent: '#0b8a5f' } },
|
||||||
|
];
|
||||||
|
const DEFAULT_THEME = THEMES[0].id;
|
||||||
|
|
||||||
|
function themeExists(id) {
|
||||||
|
return THEMES.some((t) => t.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentTheme = themeExists(shellConfig.theme) ? shellConfig.theme : DEFAULT_THEME;
|
||||||
|
|
||||||
|
/** What GET /api/theme returns — public, since dashboards need it before anyone logs in. */
|
||||||
|
function themeView() {
|
||||||
|
return {
|
||||||
|
theme: currentTheme,
|
||||||
|
themes: THEMES.map((t) => ({ id: t.id, name: t.name, colors: { ...t.colors } })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the theme into this machine's proddash.json (every other key there
|
||||||
|
* — port, adminPasscode, repo… — is kept), remember it, and tell every open
|
||||||
|
* dashboard and admin page. Throws an httpError for an unknown id or an
|
||||||
|
* unreadable settings file; the route turns that into the response.
|
||||||
|
*/
|
||||||
|
function setTheme(id) {
|
||||||
|
if (!themeExists(id)) throw httpError(400, `Unknown theme "${id}".`);
|
||||||
|
const file = path.join(DATA_DIR, 'proddash.json');
|
||||||
|
let existing = {};
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
existing = readJson(file, null);
|
||||||
|
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
|
||||||
|
throw httpError(500, `${file} is not a JSON object — fix it by hand before changing the theme.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJson(file, { ...existing, theme: id });
|
||||||
|
currentTheme = id;
|
||||||
|
shellConfig.theme = id;
|
||||||
|
broadcastShellEvent('theme', { theme: id });
|
||||||
|
return themeView();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the repo: module catalog and shell updates ──────────────────────── */
|
||||||
|
|
||||||
|
/* The admin page installs modules from, and updates the shell against, the
|
||||||
|
ProdDash repository on GitHub — one small tar.gz of the branch, fetched at
|
||||||
|
most every few minutes, parsed here with no dependencies. Nothing is
|
||||||
|
contacted unless someone opens the admin page or the periodic update
|
||||||
|
check runs; every failure is recorded and shown, never thrown at a page. */
|
||||||
|
|
||||||
|
function parseRepoSetting(value, fallbackBranch) {
|
||||||
|
const m = String(value || '').trim().match(/^([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:#([\w./-]+))?$/);
|
||||||
|
if (!m) return null;
|
||||||
|
return { owner: m[1], repo: m[2], branch: m[3] || fallbackBranch || 'main' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPO = parseRepoSetting(process.env.PRODDASH_REPO || shellConfig.repo, shellConfig.branch)
|
||||||
|
|| parseRepoSetting('michaelmcgary2008/ProdDash', 'main');
|
||||||
|
/** Where branch archives come from; tests point this at a local mock. */
|
||||||
|
const ARCHIVE_BASE = String(process.env.PRODDASH_ARCHIVE_BASE || 'https://codeload.github.com').replace(/\/+$/, '');
|
||||||
|
const REMOTE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const REMOTE_CHECK_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const MAX_ARCHIVE_BYTES = 80 * 1024 * 1024;
|
||||||
|
|
||||||
|
function tarballUrl() {
|
||||||
|
return `${ARCHIVE_BASE}/${REPO.owner}/${REPO.repo}/tar.gz/${encodeURIComponent(REPO.branch)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpError(status, message) {
|
||||||
|
const err = new Error(message);
|
||||||
|
err.status = status;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJson(buf) {
|
||||||
|
try { return buf ? JSON.parse(buf.toString('utf8')) : null; } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A relative path that can only land inside the folder it is joined to. */
|
||||||
|
function safeRelPath(p) {
|
||||||
|
if (typeof p !== 'string' || !p || p.startsWith('/') || p.includes('\\') || p.includes('\0')) return false;
|
||||||
|
return p.split('/').every((seg) => seg !== '' && seg !== '.' && seg !== '..');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — a minimal tar reader: regular files, GNU long names, pax headers — */
|
||||||
|
|
||||||
|
function cstr(buf, start, len) {
|
||||||
|
const s = buf.subarray(start, start + len);
|
||||||
|
const nul = s.indexOf(0);
|
||||||
|
return s.subarray(0, nul === -1 ? len : nul).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pax records look like "27 path=some/long/name\n" (length counts itself). */
|
||||||
|
function parsePaxRecords(buf) {
|
||||||
|
const out = {};
|
||||||
|
let pos = 0;
|
||||||
|
while (pos < buf.length) {
|
||||||
|
const sp = buf.indexOf(0x20, pos);
|
||||||
|
if (sp === -1) break;
|
||||||
|
const len = parseInt(buf.subarray(pos, sp).toString(), 10);
|
||||||
|
if (!Number.isFinite(len) || len <= 0) break;
|
||||||
|
const rec = buf.subarray(sp + 1, pos + len - 1).toString('utf8');
|
||||||
|
const eq = rec.indexOf('=');
|
||||||
|
if (eq > 0) out[rec.slice(0, eq)] = rec.slice(eq + 1);
|
||||||
|
pos += len;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTar(buf) {
|
||||||
|
const files = new Map();
|
||||||
|
let comment = '';
|
||||||
|
let pendingName = null;
|
||||||
|
let off = 0;
|
||||||
|
while (off + 512 <= buf.length) {
|
||||||
|
const h = buf.subarray(off, off + 512);
|
||||||
|
if (h.every((b) => b === 0)) break; // end-of-archive blocks
|
||||||
|
const size = parseInt(cstr(h, 124, 12).trim() || '0', 8) || 0;
|
||||||
|
const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]);
|
||||||
|
const prefix = cstr(h, 257, 6).startsWith('ustar') ? cstr(h, 345, 155) : '';
|
||||||
|
const shortName = prefix ? `${prefix}/${cstr(h, 0, 100)}` : cstr(h, 0, 100);
|
||||||
|
const data = buf.subarray(off + 512, off + 512 + size);
|
||||||
|
off += 512 + Math.ceil(size / 512) * 512;
|
||||||
|
if (type === 'L') { pendingName = cstr(data, 0, size); continue; } // GNU long name
|
||||||
|
if (type === 'g' || type === 'x') { // pax headers
|
||||||
|
const rec = parsePaxRecords(data);
|
||||||
|
if (rec.comment && !comment) comment = String(rec.comment).trim(); // GitHub: the commit sha
|
||||||
|
if (type === 'x' && rec.path) pendingName = rec.path;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const name = pendingName || shortName;
|
||||||
|
pendingName = null;
|
||||||
|
if (type === '0') files.set(name, Buffer.from(data));
|
||||||
|
}
|
||||||
|
return { files, comment };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download the branch archive → { files: Map<relative path, Buffer>, sha }. */
|
||||||
|
async function fetchArchive() {
|
||||||
|
const res = await fetch(tarballUrl(), {
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
headers: { 'User-Agent': `ProdDash/${SHELL_VERSION}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${REPO.owner}/${REPO.repo}@${REPO.branch}`);
|
||||||
|
const gz = Buffer.from(await res.arrayBuffer());
|
||||||
|
if (gz.length > MAX_ARCHIVE_BYTES) throw new Error('archive is unexpectedly large');
|
||||||
|
const { files, comment } = parseTar(zlib.gunzipSync(gz));
|
||||||
|
const stripped = new Map(); // drop the archive's single top-level folder
|
||||||
|
for (const [p, data] of files) {
|
||||||
|
const i = p.indexOf('/');
|
||||||
|
if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), data);
|
||||||
|
}
|
||||||
|
return { files: stripped, sha: /^[0-9a-f]{7,40}$/.test(comment) ? comment : '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What we last learned about the repo. */
|
||||||
|
let remote = { checkedAt: 0, checking: null, error: '', sha: '', version: '', modules: new Map(), files: null };
|
||||||
|
|
||||||
|
async function refreshRemote(force = false) {
|
||||||
|
const fresh = remote.checkedAt && Date.now() - remote.checkedAt < REMOTE_TTL_MS;
|
||||||
|
if (!force && fresh && !remote.error) return remote;
|
||||||
|
if (remote.checking) return remote.checking;
|
||||||
|
const job = (async () => {
|
||||||
|
try {
|
||||||
|
const { files, sha } = await fetchArchive();
|
||||||
|
const pkg = safeJson(files.get('package.json'));
|
||||||
|
const modules = new Map();
|
||||||
|
for (const [p, data] of files) {
|
||||||
|
const m = p.match(/^modules\/([^/]+)\/(.+)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
const entry = modules.get(m[1]) || { id: m[1], manifest: null, files: new Map() };
|
||||||
|
entry.files.set(m[2], data);
|
||||||
|
modules.set(m[1], entry);
|
||||||
|
}
|
||||||
|
for (const [id, entry] of modules) {
|
||||||
|
const man = safeJson(entry.files.get('module.json'));
|
||||||
|
if (!man || typeof man !== 'object' || (man.id && man.id !== id) || !man.client) {
|
||||||
|
modules.delete(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entry.manifest = { ...man, id };
|
||||||
|
}
|
||||||
|
remote = { checkedAt: Date.now(), checking: null, error: '', sha, version: String(pkg?.version || ''), modules, files };
|
||||||
|
} catch (err) {
|
||||||
|
remote = { ...remote, checkedAt: Date.now(), checking: null, error: err?.message || String(err) };
|
||||||
|
}
|
||||||
|
return remote;
|
||||||
|
})();
|
||||||
|
remote.checking = job;
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — this checkout — */
|
||||||
|
|
||||||
|
function git(args, timeout = 15000) {
|
||||||
|
// fileMode=false: a chmod (e.g. making the launcher executable) is not a local change
|
||||||
|
return execFileSync('git', ['-c', 'core.fileMode=false', ...args], { cwd: ROOT, encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function localGitInfo() {
|
||||||
|
if (!fs.existsSync(path.join(ROOT, '.git'))) return { isGit: false, sha: '', branch: '', dirty: false, error: '' };
|
||||||
|
try {
|
||||||
|
const porcelain = git(['status', '--porcelain']).split('\n').filter((l) => l && !l.startsWith('??'));
|
||||||
|
return { isGit: true, sha: git(['rev-parse', 'HEAD']), branch: git(['rev-parse', '--abbrev-ref', 'HEAD']), dirty: porcelain.length > 0, error: '' };
|
||||||
|
} catch (err) {
|
||||||
|
return { isGit: true, sha: '', branch: '', dirty: false, error: (err?.message || String(err)).split('\n')[0] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellStatus() {
|
||||||
|
const local = localGitInfo();
|
||||||
|
const versionCmp = remote.version ? compareVersions(remote.version, SHELL_VERSION) : 0;
|
||||||
|
const versionBehind = versionCmp > 0;
|
||||||
|
const sameCommit = !local.sha || !remote.sha || local.sha.startsWith(remote.sha) || remote.sha.startsWith(local.sha);
|
||||||
|
const commitsBehind = !sameCommit && versionCmp >= 0;
|
||||||
|
let blocker = '';
|
||||||
|
if (local.isGit) {
|
||||||
|
if (local.error) blocker = `git is not usable here (${local.error})`;
|
||||||
|
else if (local.branch !== REPO.branch) blocker = `this checkout is on branch "${local.branch}", not "${REPO.branch}"`;
|
||||||
|
else if (local.dirty) blocker = 'this checkout has local changes — commit or discard them first';
|
||||||
|
}
|
||||||
|
const gh = `https://github.com/${REPO.owner}/${REPO.repo}`;
|
||||||
|
return {
|
||||||
|
version: SHELL_VERSION,
|
||||||
|
repo: `${REPO.owner}/${REPO.repo}`,
|
||||||
|
branch: REPO.branch,
|
||||||
|
local: { isGit: local.isGit, sha: local.sha, branch: local.branch, dirty: local.dirty },
|
||||||
|
remote: { version: remote.version, sha: remote.sha, checkedAt: remote.checkedAt, error: remote.error },
|
||||||
|
updateAvailable: versionBehind || commitsBehind,
|
||||||
|
versionBehind,
|
||||||
|
method: local.isGit ? 'git' : 'archive',
|
||||||
|
updateBlocker: blocker,
|
||||||
|
changesUrl: local.sha && remote.sha
|
||||||
|
? `${gh}/compare/${local.sha.slice(0, 12)}...${remote.sha.slice(0, 12)}`
|
||||||
|
: `${gh}/commits/${REPO.branch}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — the catalog: what could be installed or updated — */
|
||||||
|
|
||||||
|
function catalogView() {
|
||||||
|
const available = [];
|
||||||
|
const updates = [];
|
||||||
|
const hiddenBundled = readManifests(BUNDLED_MODULES_DIR, 'bundled').filter((m) => isUninstalled(m.id));
|
||||||
|
const seen = new Set();
|
||||||
|
for (const [id, entry] of remote.modules) {
|
||||||
|
const man = entry.manifest;
|
||||||
|
seen.add(id);
|
||||||
|
const requires = String(man.proddash || '').trim();
|
||||||
|
const compatible = satisfiesVersion(SHELL_VERSION, requires);
|
||||||
|
const base = {
|
||||||
|
id,
|
||||||
|
name: String(man.name || id),
|
||||||
|
version: String(man.version || ''),
|
||||||
|
description: String(man.description || ''),
|
||||||
|
requires,
|
||||||
|
compatible,
|
||||||
|
reason: compatible ? '' : requirementNote(requires),
|
||||||
|
};
|
||||||
|
const installed = manifests.get(id);
|
||||||
|
if (installed) {
|
||||||
|
if (compareVersions(man.version, installed.version) > 0) {
|
||||||
|
updates.push({ ...base, installedVersion: String(installed.version || ''), source: 'repo' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const hidden = hiddenBundled.find((m) => m.id === id);
|
||||||
|
const useBundled = hidden && compareVersions(hidden.version, man.version) >= 0;
|
||||||
|
available.push({ ...base, source: useBundled ? 'bundled' : 'repo' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const m of hiddenBundled) {
|
||||||
|
if (seen.has(m.id)) continue;
|
||||||
|
available.push({
|
||||||
|
id: m.id, name: String(m.name || m.id), version: String(m.version || ''), description: String(m.description || ''),
|
||||||
|
requires: m.requires, compatible: !m.incompatible, reason: m.incompatible, source: 'bundled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||||
|
return { available: available.sort(byName), updates: updates.sort(byName) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Install (or update) a module from the repo, or bring back a bundled one. */
|
||||||
|
async function installModule(id) {
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) throw httpError(400, 'Bad module id.');
|
||||||
|
await refreshRemote(false);
|
||||||
|
const remoteEntry = remote.modules.get(id) || null;
|
||||||
|
const bundledMan = readManifests(BUNDLED_MODULES_DIR, 'bundled').find((m) => m.id === id) || null;
|
||||||
|
const installedMan = readManifests(INSTALLED_MODULES_DIR, 'installed').find((m) => m.id === id) || null;
|
||||||
|
if (!remoteEntry && !bundledMan && !installedMan) {
|
||||||
|
throw httpError(404, remote.error
|
||||||
|
? `"${id}" is not here and the repo could not be reached: ${remote.error}`
|
||||||
|
: `"${id}" is not in ${REPO.owner}/${REPO.repo}.`);
|
||||||
|
}
|
||||||
|
// End up with the newest copy: what the repo offers vs what is already here.
|
||||||
|
const haveVersion = installedMan?.version || bundledMan?.version || '';
|
||||||
|
const download = Boolean(remoteEntry) && (!(bundledMan || installedMan) || compareVersions(remoteEntry.manifest.version, haveVersion) > 0);
|
||||||
|
const man = download ? remoteEntry.manifest : (installedMan || bundledMan);
|
||||||
|
const requires = String(man.proddash || '').trim();
|
||||||
|
if (!satisfiesVersion(SHELL_VERSION, requires)) {
|
||||||
|
throw httpError(409, `${man.name || id} ${requirementNote(requires).replace('Requires', 'requires')}. Update ProdDash first.`);
|
||||||
|
}
|
||||||
|
if (download) {
|
||||||
|
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
for (const [rel, data] of remoteEntry.files) {
|
||||||
|
if (!safeRelPath(rel)) continue;
|
||||||
|
const target = path.join(staging, rel);
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
|
fs.writeFileSync(target, data);
|
||||||
|
}
|
||||||
|
const dest = path.join(INSTALLED_MODULES_DIR, id);
|
||||||
|
unmountModule(id);
|
||||||
|
fs.rmSync(dest, { recursive: true, force: true });
|
||||||
|
fs.renameSync(staging, dest);
|
||||||
|
}
|
||||||
|
const state = { ...moduleState(id) };
|
||||||
|
delete state.uninstalled;
|
||||||
|
modulesConfig[id] = state;
|
||||||
|
writeJson(MODULES_CONFIG_PATH, modulesConfig);
|
||||||
|
discoverModules();
|
||||||
|
remountModule(id);
|
||||||
|
broadcastShellEvent('modules-changed', { id });
|
||||||
|
const now = manifests.get(id);
|
||||||
|
console.log(`[proddash] ${download ? 'installed' : 'restored'} module "${id}" v${now?.version || '?'} (${now?.source || 'unknown'})`);
|
||||||
|
return { id, version: String(now?.version || ''), source: now?.source || '', downloaded: download };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a module: an installed copy is deleted; a bundled one is switched
|
||||||
|
off and hidden (its folder belongs to the app). Settings are kept so a
|
||||||
|
later reinstall picks them up again. */
|
||||||
|
function uninstallModule(id) {
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) throw httpError(400, 'Bad module id.');
|
||||||
|
const installedDir = path.join(INSTALLED_MODULES_DIR, id);
|
||||||
|
const hadInstalled = fs.existsSync(path.join(installedDir, 'module.json'));
|
||||||
|
const hasBundled = fs.existsSync(path.join(BUNDLED_MODULES_DIR, id, 'module.json'));
|
||||||
|
if (!manifests.has(id) && !hadInstalled && !hasBundled) throw httpError(404, `No module "${id}" is installed.`);
|
||||||
|
unmountModule(id);
|
||||||
|
if (hadInstalled) fs.rmSync(installedDir, { recursive: true, force: true });
|
||||||
|
modulesConfig[id] = { ...moduleState(id), uninstalled: true };
|
||||||
|
writeJson(MODULES_CONFIG_PATH, modulesConfig);
|
||||||
|
discoverModules();
|
||||||
|
broadcastShellEvent('modules-changed', { id });
|
||||||
|
console.log(`[proddash] uninstalled module "${id}"${hadInstalled ? ' (files removed)' : ''}${hasBundled ? ' (bundled copy hidden)' : ''}`);
|
||||||
|
return { id, removedFiles: hadInstalled, bundled: hasBundled };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — updating the shell itself — */
|
||||||
|
|
||||||
|
function copyTree(src, dst) {
|
||||||
|
fs.mkdirSync(dst, { recursive: true });
|
||||||
|
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
||||||
|
const s = path.join(src, e.name);
|
||||||
|
const d = path.join(dst, e.name);
|
||||||
|
if (e.isDirectory()) copyTree(s, d);
|
||||||
|
else fs.copyFileSync(s, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring the app folder up to the repo's branch. A git checkout gets
|
||||||
|
* `git pull --ff-only`; anything else has the archive unpacked over it
|
||||||
|
* (runtime state and .git are never touched). The caller restarts.
|
||||||
|
*/
|
||||||
|
async function updateShell() {
|
||||||
|
await refreshRemote(true);
|
||||||
|
if (remote.error) throw httpError(502, `Couldn't reach ${REPO.owner}/${REPO.repo}: ${remote.error}`);
|
||||||
|
const status = shellStatus();
|
||||||
|
if (status.updateBlocker) throw httpError(409, `Can't update from here: ${status.updateBlocker}.`);
|
||||||
|
if (!status.updateAvailable) throw httpError(409, 'Already up to date.');
|
||||||
|
if (status.local.isGit) {
|
||||||
|
const out = git(['pull', '--ff-only', 'origin', REPO.branch], 120000);
|
||||||
|
console.log('[proddash] git pull:', out.split('\n').filter(Boolean).slice(-2).join(' | '));
|
||||||
|
} else {
|
||||||
|
const keep = (rel) => rel === 'config/modules.json' || rel.startsWith('config/layouts/') || rel.startsWith('.git/');
|
||||||
|
const staging = path.join(ROOT, '.proddash-update');
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
for (const [rel, data] of remote.files) {
|
||||||
|
if (!safeRelPath(rel) || keep(rel)) continue;
|
||||||
|
const target = path.join(staging, rel);
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
|
fs.writeFileSync(target, data);
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(path.join(staging, 'server.js'))) {
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
throw httpError(502, 'The downloaded archive does not look like ProdDash (no server.js).');
|
||||||
|
}
|
||||||
|
copyTree(staging, ROOT);
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
return { from: SHELL_VERSION, to: remote.version || '', method: status.method };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exit so the new code runs. Exit code 75 tells a launcher loop to start us
|
||||||
|
* again; when nothing is supervising (PRODDASH_LAUNCHER unset) we start our
|
||||||
|
* own replacement, which waits for this port to free up.
|
||||||
|
*/
|
||||||
|
function restartServer(reason) {
|
||||||
|
console.log(`[proddash] ${reason} — restarting ProdDash`);
|
||||||
|
for (const id of [...mounted.keys()]) unmountModule(id);
|
||||||
|
let done = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
if (!process.env.PRODDASH_LAUNCHER) {
|
||||||
|
try {
|
||||||
|
const child = spawn(process.execPath, process.argv.slice(1), {
|
||||||
|
cwd: process.cwd(), env: process.env, detached: true, stdio: 'inherit',
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[proddash] could not start the new ProdDash — start it by hand:', err?.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.exit(75);
|
||||||
|
};
|
||||||
|
server.close(finish);
|
||||||
|
setTimeout(finish, 1500).unref();
|
||||||
|
}
|
||||||
|
|
||||||
/* ── admin API ──────────────────────────────────────────────────────── */
|
/* ── admin API ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
|
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
|
||||||
@@ -390,8 +1028,12 @@ function adminModuleView(id) {
|
|||||||
version: String(man.version || ''),
|
version: String(man.version || ''),
|
||||||
description: String(man.description || ''),
|
description: String(man.description || ''),
|
||||||
hasServer: Boolean(man.server),
|
hasServer: Boolean(man.server),
|
||||||
|
source: man.source || 'bundled',
|
||||||
|
requires: man.requires || '',
|
||||||
|
incompatible: man.incompatible || '',
|
||||||
enabled: isEnabled(id),
|
enabled: isEnabled(id),
|
||||||
configSchema: man.configSchema || {},
|
configSchema: man.configSchema || {},
|
||||||
|
configGroups: man.configGroups && typeof man.configGroups === 'object' ? man.configGroups : {},
|
||||||
config: values,
|
config: values,
|
||||||
passwordSet,
|
passwordSet,
|
||||||
mountError: entry?.error || '',
|
mountError: entry?.error || '',
|
||||||
@@ -412,8 +1054,14 @@ function applyConfigPatch(id, patch) {
|
|||||||
value = current[key] || '';
|
value = current[key] || '';
|
||||||
}
|
}
|
||||||
if (type === 'number') value = Number(value) || 0;
|
if (type === 'number') value = Number(value) || 0;
|
||||||
else if (type === 'boolean') value = Boolean(value);
|
else if (type === 'boolean' || type === 'switch') value = Boolean(value);
|
||||||
else value = value === undefined || value === null ? '' : String(value);
|
else if (type === 'endpoint') {
|
||||||
|
const src = value && typeof value === 'object' ? value : {};
|
||||||
|
value = {
|
||||||
|
host: String(src.host ?? '').trim(),
|
||||||
|
port: Math.max(0, Math.min(65535, Math.trunc(Number(src.port)) || 0)),
|
||||||
|
};
|
||||||
|
} else value = value === undefined || value === null ? '' : String(value);
|
||||||
next[key] = value;
|
next[key] = value;
|
||||||
}
|
}
|
||||||
modulesConfig[id] = { ...moduleState(id), enabled: isEnabled(id), config: next };
|
modulesConfig[id] = { ...moduleState(id), enabled: isEnabled(id), config: next };
|
||||||
@@ -447,10 +1095,51 @@ async function handleAdminApi(req, res, urlPath) {
|
|||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
authRequired: Boolean(ADMIN_PASSCODE),
|
authRequired: Boolean(ADMIN_PASSCODE),
|
||||||
authed: true,
|
authed: true,
|
||||||
|
version: SHELL_VERSION,
|
||||||
|
dataDir: DATA_DIR,
|
||||||
modules: [...manifests.keys()].sort().map(adminModuleView),
|
modules: [...manifests.keys()].sort().map(adminModuleView),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (urlPath === '/api/admin/catalog' && req.method === 'GET') {
|
||||||
|
if (!isAuthed(req)) return sendJson(res, 401, { authRequired: true, authed: false });
|
||||||
|
const refresh = new URL(req.url, 'http://proddash.invalid').searchParams.get('refresh') === '1';
|
||||||
|
await refreshRemote(refresh);
|
||||||
|
return sendJson(res, 200, { shell: shellStatus(), ...catalogView() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlPath === '/api/admin/update' && req.method === 'POST') {
|
||||||
|
if (refuseAdminWrite(req, res)) return;
|
||||||
|
try {
|
||||||
|
const result = await updateShell();
|
||||||
|
sendJson(res, 200, { ok: true, ...result, restarting: true });
|
||||||
|
setTimeout(() => restartServer(`updated ${result.from} → ${result.to || 'latest'} via ${result.method}`), 400);
|
||||||
|
} catch (err) {
|
||||||
|
sendJson(res, err.status || 500, { error: err.message || String(err) });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const install = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/install$/);
|
||||||
|
if (install && req.method === 'POST') {
|
||||||
|
if (refuseAdminWrite(req, res)) return;
|
||||||
|
try {
|
||||||
|
return sendJson(res, 200, { ok: true, ...(await installModule(decodeURIComponent(install[1]))) });
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, err.status || 500, { error: err.message || String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = urlPath.match(/^\/api\/admin\/modules\/([^/]+)$/);
|
||||||
|
if (remove && req.method === 'DELETE') {
|
||||||
|
if (refuseAdminWrite(req, res)) return;
|
||||||
|
try {
|
||||||
|
return sendJson(res, 200, { ok: true, ...uninstallModule(decodeURIComponent(remove[1])) });
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, err.status || 500, { error: err.message || String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const m = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/(config|enabled)$/);
|
const m = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/(config|enabled)$/);
|
||||||
if (m && req.method === 'PUT') {
|
if (m && req.method === 'PUT') {
|
||||||
const id = decodeURIComponent(m[1]);
|
const id = decodeURIComponent(m[1]);
|
||||||
@@ -462,25 +1151,43 @@ async function handleAdminApi(req, res, urlPath) {
|
|||||||
} catch {
|
} catch {
|
||||||
return sendJson(res, 400, { error: 'Malformed request.' });
|
return sendJson(res, 400, { error: 'Malformed request.' });
|
||||||
}
|
}
|
||||||
if (m[2] === 'config') {
|
try {
|
||||||
applyConfigPatch(id, body.config);
|
if (m[2] === 'config') {
|
||||||
remountModule(id); // config changes take effect without a manual restart
|
applyConfigPatch(id, body.config);
|
||||||
} else {
|
} else {
|
||||||
const enabled = Boolean(body.enabled);
|
modulesConfig[id] = { ...moduleState(id), enabled: Boolean(body.enabled) };
|
||||||
modulesConfig[id] = { ...moduleState(id), enabled };
|
writeJson(MODULES_CONFIG_PATH, modulesConfig);
|
||||||
writeJson(MODULES_CONFIG_PATH, modulesConfig);
|
}
|
||||||
remountModule(id); // mounts when enabled, unmounts (only) otherwise
|
} catch (err) {
|
||||||
|
return sendJson(res, 500, { error: `Could not save ${MODULES_CONFIG_PATH}: ${err?.message || err}` });
|
||||||
}
|
}
|
||||||
|
// Config changes take effect without a manual restart; a toggle mounts when
|
||||||
|
// enabled and unmounts (only) otherwise.
|
||||||
|
remountModule(id);
|
||||||
broadcastShellEvent('modules-changed', { id });
|
broadcastShellEvent('modules-changed', { id });
|
||||||
return sendJson(res, 200, { ok: true, module: adminModuleView(id) });
|
return sendJson(res, 200, { ok: true, module: adminModuleView(id) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* — theme: { "theme": "<id>" }, validated against THEMES — */
|
||||||
|
if (urlPath === '/api/admin/theme' && req.method === 'POST') {
|
||||||
|
if (refuseAdminWrite(req, res)) return;
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
body = JSON.parse((await readBody(req)) || '{}');
|
||||||
|
} catch {
|
||||||
|
return sendJson(res, 400, { error: 'Malformed request.' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return sendJson(res, 200, { ok: true, ...setTheme(String(body.theme ?? '')) });
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, err.status || 500, { error: err.message || String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sendJson(res, 404, { error: 'Unknown admin endpoint.' });
|
sendJson(res, 404, { error: 'Unknown admin endpoint.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── named layouts (JSON files under config/layouts/) ───────────────── */
|
/* ── named layouts (JSON files under <data dir>/layouts/) ───────────── */
|
||||||
|
|
||||||
const LAYOUTS_DIR = path.join(CONFIG_DIR, 'layouts');
|
|
||||||
|
|
||||||
/** Names double as file names — keep them boring on purpose. */
|
/** Names double as file names — keep them boring on purpose. */
|
||||||
function layoutNameOk(name) {
|
function layoutNameOk(name) {
|
||||||
@@ -558,7 +1265,11 @@ async function handleLayoutsApi(req, res, urlPath) {
|
|||||||
}
|
}
|
||||||
const layout = sanitizeLayout(body.layout);
|
const layout = sanitizeLayout(body.layout);
|
||||||
if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' });
|
if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' });
|
||||||
writeJson(layoutPath(name), layout);
|
try {
|
||||||
|
writeJson(layoutPath(name), layout);
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 500, { error: `Could not save ${layoutPath(name)}: ${err?.message || err}` });
|
||||||
|
}
|
||||||
return sendJson(res, 200, { ok: true, name });
|
return sendJson(res, 200, { ok: true, name });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,6 +1315,7 @@ async function handleLayoutsApi(req, res, urlPath) {
|
|||||||
function dispatchModuleApi(req, res, id, subPath, search) {
|
function dispatchModuleApi(req, res, id, subPath, search) {
|
||||||
if (!manifests.has(id)) return sendJson(res, 404, { error: `No module "${id}" is installed.` });
|
if (!manifests.has(id)) return sendJson(res, 404, { error: `No module "${id}" is installed.` });
|
||||||
if (!isEnabled(id)) return sendJson(res, 404, { error: `Module "${id}" is disabled.` });
|
if (!isEnabled(id)) return sendJson(res, 404, { error: `Module "${id}" is disabled.` });
|
||||||
|
if (manifests.get(id).incompatible) return sendJson(res, 409, { error: manifests.get(id).incompatible });
|
||||||
const entry = mounted.get(id);
|
const entry = mounted.get(id);
|
||||||
if (!entry) return sendJson(res, 503, { error: `Module "${id}" is not mounted.` });
|
if (!entry) return sendJson(res, 503, { error: `Module "${id}" is not mounted.` });
|
||||||
if (entry.error) return sendJson(res, 502, { error: `Module "${id}" failed to start: ${entry.error}` });
|
if (entry.error) return sendJson(res, 502, { error: `Module "${id}" failed to start: ${entry.error}` });
|
||||||
@@ -706,7 +1418,7 @@ function handleRequest(req, res) {
|
|||||||
|
|
||||||
/* — shell API — */
|
/* — shell API — */
|
||||||
if (urlPath === '/api/modules' && req.method === 'GET') {
|
if (urlPath === '/api/modules' && req.method === 'GET') {
|
||||||
Promise.all([...manifests.keys()].filter(isEnabled).map(clientManifest))
|
Promise.all([...manifests.keys()].filter(isActive).map(clientManifest))
|
||||||
.then((list) => sendJson(res, 200, { modules: list }))
|
.then((list) => sendJson(res, 200, { modules: list }))
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('[proddash] /api/modules failed:', err);
|
console.error('[proddash] /api/modules failed:', err);
|
||||||
@@ -715,6 +1427,11 @@ function handleRequest(req, res) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* — theme: public (dashboards need it without a passcode); changed via POST /api/admin/theme — */
|
||||||
|
if (urlPath === '/api/theme' && req.method === 'GET') {
|
||||||
|
return sendJson(res, 200, themeView());
|
||||||
|
}
|
||||||
|
|
||||||
/* — shell events: open dashboards learn about admin changes live — */
|
/* — shell events: open dashboards learn about admin changes live — */
|
||||||
if (urlPath === '/api/events' && req.method === 'GET') {
|
if (urlPath === '/api/events' && req.method === 'GET') {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
@@ -773,7 +1490,7 @@ function handleRequest(req, res) {
|
|||||||
if (assetMatch) {
|
if (assetMatch) {
|
||||||
const id = decodeURIComponent(assetMatch[1]);
|
const id = decodeURIComponent(assetMatch[1]);
|
||||||
const man = manifests.get(id);
|
const man = manifests.get(id);
|
||||||
if (!man || !isEnabled(id)) {
|
if (!man || !isActive(id)) {
|
||||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
return res.end('Not found');
|
return res.end('Not found');
|
||||||
}
|
}
|
||||||
@@ -804,8 +1521,8 @@ server.requestTimeout = 0;
|
|||||||
discoverModules();
|
discoverModules();
|
||||||
mountAllModules();
|
mountAllModules();
|
||||||
|
|
||||||
server.listen(PORT, '0.0.0.0', () => {
|
server.on('listening', () => {
|
||||||
console.log('ProdDash running');
|
console.log('ProdDash ' + SHELL_VERSION);
|
||||||
console.log(' Local : http://localhost:' + PORT);
|
console.log(' Local : http://localhost:' + PORT);
|
||||||
for (const ifaces of Object.values(os.networkInterfaces())) {
|
for (const ifaces of Object.values(os.networkInterfaces())) {
|
||||||
for (const iface of ifaces || []) {
|
for (const iface of ifaces || []) {
|
||||||
@@ -816,10 +1533,34 @@ server.listen(PORT, '0.0.0.0', () => {
|
|||||||
}
|
}
|
||||||
const ids = [...manifests.keys()];
|
const ids = [...manifests.keys()];
|
||||||
console.log(ids.length
|
console.log(ids.length
|
||||||
? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)')).join(', ')
|
? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)') + (manifests.get(id).incompatible ? ' (needs newer ProdDash)' : '')).join(', ')
|
||||||
: ' Modules : none installed');
|
: ' Modules : none installed');
|
||||||
|
console.log(' Settings : ' + DATA_DIR + (DATA_DIR === REPO_CONFIG_DIR ? ' (inside the app folder)' : ''));
|
||||||
|
console.log(' Repo : ' + REPO.owner + '/' + REPO.repo + '@' + REPO.branch);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A restart hands the port over from the exiting ProdDash: wait for it.
|
||||||
|
let listenTries = 0;
|
||||||
|
server.on('error', (err) => {
|
||||||
|
if (err.code === 'EADDRINUSE' && listenTries < 30) {
|
||||||
|
listenTries += 1;
|
||||||
|
if (listenTries === 1) console.log(`[proddash] port ${PORT} is busy — waiting for it (a previous ProdDash may still be shutting down)`);
|
||||||
|
setTimeout(() => server.listen(PORT, '0.0.0.0'), 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('[proddash] cannot start:', err.message || err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, '0.0.0.0');
|
||||||
|
|
||||||
|
// Update check: shortly after boot, then every few hours (the admin page
|
||||||
|
// shows the result; nothing is installed without someone clicking).
|
||||||
|
if (!process.env.PRODDASH_NO_REMOTE_CHECK) {
|
||||||
|
setTimeout(() => refreshRemote(false).catch(() => {}), 20000).unref();
|
||||||
|
setInterval(() => refreshRemote(true).catch(() => {}), REMOTE_CHECK_MS).unref();
|
||||||
|
}
|
||||||
|
|
||||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||||
process.on(signal, () => {
|
process.on(signal, () => {
|
||||||
for (const id of [...mounted.keys()]) unmountModule(id);
|
for (const id of [...mounted.keys()]) unmountModule(id);
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
/**
|
||||||
|
* Mock Planning Center Services API for developing the pco-plan module
|
||||||
|
* without a Planning Center account.
|
||||||
|
*
|
||||||
|
* node tools/pco-mock.js [port] (default 24700)
|
||||||
|
* PCO_PLAN_API_BASE=http://127.0.0.1:24700 node server.js
|
||||||
|
*
|
||||||
|
* Serves JSON:API shapes matching api.planningcenteronline.com/services/v2:
|
||||||
|
* GET /services/v2/folders nested folders (parent rel)
|
||||||
|
* GET /services/v2/folders/{id}
|
||||||
|
* GET /services/v2/service_types every service type, with its
|
||||||
|
* parent folder relationship
|
||||||
|
* GET /services/v2/service_types/{id}
|
||||||
|
* GET /services/v2/service_types/{id}/plans ?filter=future|past, order,
|
||||||
|
* per_page — today's plan,
|
||||||
|
* next week's and last week's
|
||||||
|
* GET …/plans/{id}
|
||||||
|
* GET …/plans/{id}/plan_times rehearsal + two services; the
|
||||||
|
* first service starts 90 s
|
||||||
|
* after the mock boots so the
|
||||||
|
* "Starts in" state is visible
|
||||||
|
* GET …/plans/{id}/items?include=… items + Song / Arrangement /
|
||||||
|
* ItemNote compound documents
|
||||||
|
*
|
||||||
|
* Item titles deliberately match band-lineup-display/tmp/pp-mock.js's
|
||||||
|
* playlist, so running both mocks exercises the ProPresenter matching.
|
||||||
|
*
|
||||||
|
* Any request without an Authorization header (Basic or Bearer) gets a
|
||||||
|
* Planning-Center-style 401, so a missing/blank credential shows up the way
|
||||||
|
* it would in production. Every request is logged.
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const PORT = Number.parseInt(process.argv[2] || '24700', 10);
|
||||||
|
const bootedAt = Date.now();
|
||||||
|
|
||||||
|
/* ── data ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const FOLDERS = [
|
||||||
|
{ id: '100', name: 'Campuses', parentId: '' },
|
||||||
|
{ id: '110', name: 'North Campus', parentId: '100' },
|
||||||
|
{ id: '120', name: 'Downtown', parentId: '100' },
|
||||||
|
{ id: '200', name: 'Archive', parentId: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SERVICE_TYPES = [
|
||||||
|
{ id: '1001', name: 'Sunday Service', frequency: 'Every week', parentId: '110' },
|
||||||
|
{ id: '1002', name: 'Wednesday Night', frequency: 'Every week', parentId: '110' },
|
||||||
|
{ id: '1003', name: 'Downtown Sunday', frequency: 'Every week', parentId: '120' },
|
||||||
|
{ id: '1004', name: 'Christmas Eve 2024', frequency: 'Every 12 months', parentId: '200' },
|
||||||
|
{ id: '1005', name: 'Youth Night', frequency: 'Every 2 weeks', parentId: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function iso(ms) {
|
||||||
|
return new Date(ms).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function longDate(ms) {
|
||||||
|
return new Date(ms).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(ms) {
|
||||||
|
return new Date(ms).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY = 86400000;
|
||||||
|
const firstServiceStart = bootedAt + 90 * 1000;
|
||||||
|
|
||||||
|
/** Plans for service type 1001: last week, today, next week. */
|
||||||
|
function plansFor(stId) {
|
||||||
|
if (stId !== '1001' && stId !== '1003') return [];
|
||||||
|
const mk = (id, serviceStart, title, series) => ({
|
||||||
|
id,
|
||||||
|
serviceTypeId: stId,
|
||||||
|
title,
|
||||||
|
series,
|
||||||
|
sortDate: serviceStart,
|
||||||
|
times: [
|
||||||
|
{ id: `${id}-r`, name: 'Rehearsal', type: 'rehearsal', start: serviceStart - 60 * 60000, end: serviceStart - 15 * 60000 },
|
||||||
|
{ id: `${id}-s1`, name: 'First Service', type: 'service', start: serviceStart, end: serviceStart + 75 * 60000 },
|
||||||
|
{ id: `${id}-s2`, name: 'Second Service', type: 'service', start: serviceStart + 120 * 60000, end: serviceStart + 195 * 60000 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
mk(`${stId}-P1`, firstServiceStart - 7 * DAY, 'Part 1', 'Living Hope'),
|
||||||
|
mk(`${stId}-P2`, firstServiceStart, 'Part 2', 'Living Hope'),
|
||||||
|
mk(`${stId}-P3`, firstServiceStart + 7 * DAY, 'Part 3', 'Living Hope'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function planRecord(p) {
|
||||||
|
const items = ITEMS.filter((it) => it.type !== 'header');
|
||||||
|
return {
|
||||||
|
type: 'Plan',
|
||||||
|
id: p.id,
|
||||||
|
attributes: {
|
||||||
|
title: p.title,
|
||||||
|
series_title: p.series,
|
||||||
|
dates: longDate(p.sortDate),
|
||||||
|
short_dates: shortDate(p.sortDate),
|
||||||
|
sort_date: iso(p.sortDate),
|
||||||
|
total_length: items.reduce((s, it) => s + it.length, 0),
|
||||||
|
items_count: ITEMS.length,
|
||||||
|
plan_notes_count: 0,
|
||||||
|
planning_center_url: `https://services.planningcenteronline.com/plans/${p.id}`,
|
||||||
|
},
|
||||||
|
relationships: { service_type: { data: { type: 'ServiceType', id: p.serviceTypeId } } },
|
||||||
|
links: { self: `/services/v2/service_types/${p.serviceTypeId}/plans/${p.id}` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Titles match pp-mock's playlist: Welcome, This Is The Day, Sermon: Living
|
||||||
|
// Hope, Bumper Video, Closing Song, Benediction.
|
||||||
|
const ITEMS = [
|
||||||
|
{ id: 'i1', type: 'header', title: 'Pre-Service', pos: 'pre', length: 0 },
|
||||||
|
{ id: 'i2', type: 'media', title: 'Countdown', pos: 'pre', length: 300, desc: 'Start 5 min before', notes: [['Video', 'Loop the countdown from 8:55']] },
|
||||||
|
{ id: 'i3', type: 'header', title: 'Worship', pos: 'during', length: 0 },
|
||||||
|
{ id: 'i4', type: 'song', title: 'This Is The Day', pos: 'during', length: 270, key: 'G', song: { id: 's1', title: 'This Is The Day', author: 'Fred Hammond' }, arr: 'Default Arrangement', notes: [['Audio', 'Lead vocal: Sarah'], ['Lighting', 'Bright — house at 40%']] },
|
||||||
|
{ id: 'i5', type: 'song', title: 'Goodness of God', pos: 'during', length: 320, key: 'A', song: { id: 's2', title: 'Goodness of God', author: 'Bethel Music', ccli: '7117726' }, arr: 'Acoustic', notes: [['ProPresenter', 'Use the [ Full ] arrangement']] },
|
||||||
|
{ id: 'i6', type: 'item', title: 'Welcome', pos: 'during', length: 180, desc: 'Pastor Mike — mention Fall Kickoff', notes: [['Audio', 'Handheld 2 hot']] },
|
||||||
|
{ id: 'i7', type: 'media', title: 'Bumper Video', pos: 'during', length: 60, notes: [['Video', 'Program to Playback A']] },
|
||||||
|
{ id: 'i8', type: 'header', title: 'Message', pos: 'during', length: 0 },
|
||||||
|
{ id: 'i9', type: 'item', title: 'Sermon: Living Hope', pos: 'during', length: 2100, desc: '1 Peter 1:3-9', notes: [['ProPresenter', 'Sermon slides from PCO'], ['Lighting', 'Stage wash, house at 20%']] },
|
||||||
|
{ id: 'i10', type: 'song', title: 'Closing Song', pos: 'during', length: 240, key: 'D', song: { id: 's3', title: 'Build My Life', author: 'Housefires' }, arr: 'Default Arrangement' },
|
||||||
|
{ id: 'i11', type: 'item', title: 'Benediction', pos: 'during', length: 90 },
|
||||||
|
{ id: 'i12', type: 'header', title: 'Post-Service', pos: 'post', length: 0 },
|
||||||
|
{ id: 'i13', type: 'media', title: 'Walk-out Loop', pos: 'post', length: 600, notes: [['Video', 'Announcements loop until the room clears']] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function itemsDocument(planId, include) {
|
||||||
|
const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean));
|
||||||
|
const data = [];
|
||||||
|
const included = [];
|
||||||
|
ITEMS.forEach((it, index) => {
|
||||||
|
const relationships = {};
|
||||||
|
if (it.song) {
|
||||||
|
relationships.song = { data: { type: 'Song', id: it.song.id } };
|
||||||
|
if (wants.has('song')) {
|
||||||
|
included.push({ type: 'Song', id: it.song.id, attributes: { title: it.song.title, author: it.song.author, ccli_number: it.song.ccli ? Number(it.song.ccli) : null } });
|
||||||
|
}
|
||||||
|
if (it.arr) {
|
||||||
|
const arrId = `${it.song.id}-arr`;
|
||||||
|
relationships.arrangement = { data: { type: 'Arrangement', id: arrId } };
|
||||||
|
if (wants.has('arrangement')) included.push({ type: 'Arrangement', id: arrId, attributes: { name: it.arr } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const noteRefs = (it.notes || []).map((n, i) => ({ type: 'ItemNote', id: `${it.id}-n${i}` }));
|
||||||
|
relationships.item_notes = { data: noteRefs };
|
||||||
|
if (wants.has('item_notes')) {
|
||||||
|
(it.notes || []).forEach(([category, content], i) => {
|
||||||
|
included.push({ type: 'ItemNote', id: `${it.id}-n${i}`, attributes: { category_name: category, content } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
data.push({
|
||||||
|
type: 'Item',
|
||||||
|
id: `${planId}-${it.id}`,
|
||||||
|
attributes: {
|
||||||
|
title: it.title,
|
||||||
|
sequence: index + 1,
|
||||||
|
item_type: it.type,
|
||||||
|
length: it.length,
|
||||||
|
service_position: it.pos,
|
||||||
|
description: it.desc || '',
|
||||||
|
html_details: '',
|
||||||
|
key_name: it.key || null,
|
||||||
|
},
|
||||||
|
relationships,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { data, included, meta: { total_count: data.length, count: data.length } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── JSON:API plumbing ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function folderRecord(f) {
|
||||||
|
return {
|
||||||
|
type: 'Folder',
|
||||||
|
id: f.id,
|
||||||
|
attributes: { name: f.name, container: f.parentId ? 'Folder' : 'Organization' },
|
||||||
|
relationships: { parent: { data: f.parentId ? { type: 'Folder', id: f.parentId } : null } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceTypeRecord(st) {
|
||||||
|
return {
|
||||||
|
type: 'ServiceType',
|
||||||
|
id: st.id,
|
||||||
|
attributes: { name: st.name, frequency: st.frequency, sequence: Number(st.id) },
|
||||||
|
relationships: { parent: { data: st.parentId ? { type: 'Folder', id: st.parentId } : null } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function planTimeRecord(t) {
|
||||||
|
return {
|
||||||
|
type: 'PlanTime',
|
||||||
|
id: t.id,
|
||||||
|
attributes: { name: t.name, time_type: t.type, starts_at: iso(t.start), ends_at: iso(t.end), live_starts_at: null, live_ends_at: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(res, status, body) {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/vnd.api+json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collection(res, records, url) {
|
||||||
|
send(res, 200, {
|
||||||
|
links: { self: url.pathname + url.search },
|
||||||
|
data: records,
|
||||||
|
included: [],
|
||||||
|
meta: { total_count: records.length, count: records.length },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function notFound(res) {
|
||||||
|
send(res, 404, { errors: [{ code: 'not_found', title: 'Not Found', status: '404', detail: 'The resource you requested could not be found' }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||||
|
console.log(`[pco-mock] ${req.method} ${url.pathname}${url.search}`);
|
||||||
|
|
||||||
|
if (!/^(Basic|Bearer) \S+/.test(String(req.headers.authorization || ''))) {
|
||||||
|
return send(res, 401, { errors: [{ code: 'unauthorized', title: 'Unauthorized', status: '401', detail: 'This request could not be authenticated.' }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = url.pathname.replace(/^\/services\/v2\/?/, '').split('/').filter(Boolean);
|
||||||
|
if (!url.pathname.startsWith('/services/v2')) return notFound(res);
|
||||||
|
|
||||||
|
// /folders[/{id}]
|
||||||
|
if (parts[0] === 'folders') {
|
||||||
|
if (parts.length === 1) return collection(res, FOLDERS.map(folderRecord), url);
|
||||||
|
const f = FOLDERS.find((x) => x.id === parts[1]);
|
||||||
|
if (!f) return notFound(res);
|
||||||
|
if (parts.length === 2) return send(res, 200, { data: folderRecord(f) });
|
||||||
|
if (parts[2] === 'folders') return collection(res, FOLDERS.filter((x) => x.parentId === f.id).map(folderRecord), url);
|
||||||
|
if (parts[2] === 'service_types') return collection(res, SERVICE_TYPES.filter((x) => x.parentId === f.id).map(serviceTypeRecord), url);
|
||||||
|
return notFound(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// /service_types[/{id}[/plans[/{pid}[/plan_times|items]]]]
|
||||||
|
if (parts[0] === 'service_types') {
|
||||||
|
if (parts.length === 1) return collection(res, SERVICE_TYPES.map(serviceTypeRecord), url);
|
||||||
|
const st = SERVICE_TYPES.find((x) => x.id === parts[1]);
|
||||||
|
if (!st) return notFound(res);
|
||||||
|
if (parts.length === 2) return send(res, 200, { data: serviceTypeRecord(st) });
|
||||||
|
if (parts[2] !== 'plans') return notFound(res);
|
||||||
|
const plans = plansFor(st.id);
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const filter = url.searchParams.get('filter') || '';
|
||||||
|
const now = Date.now();
|
||||||
|
let list = plans.slice();
|
||||||
|
if (filter === 'future') list = list.filter((p) => p.sortDate >= new Date(now).setHours(0, 0, 0, 0));
|
||||||
|
else if (filter === 'past') list = list.filter((p) => p.sortDate < now);
|
||||||
|
const order = url.searchParams.get('order') || 'sort_date';
|
||||||
|
list.sort((a, b) => (order.startsWith('-') ? b.sortDate - a.sortDate : a.sortDate - b.sortDate));
|
||||||
|
const perPage = Math.max(1, Math.min(100, Number(url.searchParams.get('per_page')) || 25));
|
||||||
|
return collection(res, list.slice(0, perPage).map(planRecord), url);
|
||||||
|
}
|
||||||
|
const plan = plans.find((p) => p.id === parts[3]);
|
||||||
|
if (!plan) return notFound(res);
|
||||||
|
if (parts.length === 4) return send(res, 200, { data: planRecord(plan) });
|
||||||
|
if (parts[4] === 'plan_times') return collection(res, plan.times.map(planTimeRecord), url);
|
||||||
|
if (parts[4] === 'items') {
|
||||||
|
const doc = itemsDocument(plan.id, url.searchParams.get('include'));
|
||||||
|
doc.links = { self: url.pathname + url.search };
|
||||||
|
return send(res, 200, doc);
|
||||||
|
}
|
||||||
|
return notFound(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
notFound(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, '127.0.0.1', () => {
|
||||||
|
console.log(`[pco-mock] Planning Center mock on http://127.0.0.1:${PORT}/services/v2`);
|
||||||
|
console.log(`[pco-mock] today's plan "${plansFor('1001')[1].title}" — first service starts at ${new Date(firstServiceStart).toLocaleTimeString()}`);
|
||||||
|
console.log('[pco-mock] run ProdDash with: PCO_PLAN_API_BASE=http://127.0.0.1:%d node server.js', PORT);
|
||||||
|
});
|
||||||
+139
-1
@@ -4,6 +4,7 @@
|
|||||||
* band-lineup-display/tmp/pp-mock.js simulates slides, not timers).
|
* band-lineup-display/tmp/pp-mock.js simulates slides, not timers).
|
||||||
*
|
*
|
||||||
* node tools/pp-timers-mock.js [port] [--no-ltc] [--drop=<uuid,...>]
|
* node tools/pp-timers-mock.js [port] [--no-ltc] [--drop=<uuid,...>]
|
||||||
|
* [--stage-pwd=<pwd>] [--no-ltc-field]
|
||||||
*
|
*
|
||||||
* Serves the endpoint shapes verified against openapi.propresenter.com:
|
* Serves the endpoint shapes verified against openapi.propresenter.com:
|
||||||
* GET /version (reachability probe)
|
* GET /version (reachability probe)
|
||||||
@@ -23,6 +24,16 @@
|
|||||||
* the mock with e.g. --drop=T2-WALKIN to simulate a timer being deleted in
|
* the mock with e.g. --drop=T2-WALKIN to simulate a timer being deleted in
|
||||||
* ProPresenter (a solo tile for it must say so, not error).
|
* ProPresenter (a solo tile for it must say so, not error).
|
||||||
*
|
*
|
||||||
|
* Also serves the classic stage-display websocket at /stagedisplay (the
|
||||||
|
* Pro6-era protocol ProPresenter still answers): ath / psl / asl / sl, a
|
||||||
|
* per-second "sys" clock, and "tmr" text updates — including a stage layout
|
||||||
|
* with a field labeled "LTC" whose text advances at 30 fps and freezes
|
||||||
|
* during the same 15 s/60 s no-signal window as the HTTP endpoint. Combine
|
||||||
|
* with --no-ltc to make the stage feed the only timecode source (the module
|
||||||
|
* prefers the HTTP route when it answers). --stage-pwd=<pwd> rejects other
|
||||||
|
* stage passwords ("Your Password is incorrect", as real ProPresenter says);
|
||||||
|
* --no-ltc-field serves the stage layout without the LTC field.
|
||||||
|
*
|
||||||
* Every incoming request is logged so we can confirm what the module fetches.
|
* Every incoming request is logged so we can confirm what the module fetches.
|
||||||
*/
|
*/
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -33,6 +44,11 @@ const args = process.argv.slice(2);
|
|||||||
const PORT = Number.parseInt(args.find((a) => /^\d+$/.test(a)) || '1600', 10);
|
const PORT = Number.parseInt(args.find((a) => /^\d+$/.test(a)) || '1600', 10);
|
||||||
const NO_LTC = args.includes('--no-ltc');
|
const NO_LTC = args.includes('--no-ltc');
|
||||||
const DROP = new Set(args.flatMap((a) => (a.startsWith('--drop=') ? a.slice(7).split(',') : [])));
|
const DROP = new Set(args.flatMap((a) => (a.startsWith('--drop=') ? a.slice(7).split(',') : [])));
|
||||||
|
const STAGE_PWD = (args.find((a) => a.startsWith('--stage-pwd=')) || '').slice(12);
|
||||||
|
const NO_LTC_FIELD = args.includes('--no-ltc-field');
|
||||||
|
// Like real ProPresenter 21: the layout list omits the timecode field, but
|
||||||
|
// its updates still stream — exercises the module's heuristic binding.
|
||||||
|
const LTC_UNLISTED = args.includes('--ltc-unlisted');
|
||||||
const FPS = 30;
|
const FPS = 30;
|
||||||
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
@@ -140,6 +156,128 @@ const server = http.createServer((req, res) => {
|
|||||||
sendJson(res, { error: 'not found' }, 404);
|
sendJson(res, { error: 'not found' }, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */
|
||||||
|
|
||||||
|
const cryptoMod = require('crypto');
|
||||||
|
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||||
|
|
||||||
|
const STAGE_LAYOUT = {
|
||||||
|
acn: 'sl',
|
||||||
|
uid: 'MOCK-LAYOUT-1',
|
||||||
|
nme: 'Timers',
|
||||||
|
fme: [
|
||||||
|
{ nme: 'Current Slide', typ: 1 },
|
||||||
|
...(NO_LTC_FIELD || LTC_UNLISTED ? [] : [{ nme: 'LTC', typ: 7, uid: 'LTC-FIELD' }]),
|
||||||
|
{ nme: 'Sermon Countdown', typ: 7, uid: 'T1-SERMON' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Server→client frames are unmasked. Text only — plenty for this protocol. */
|
||||||
|
function wsEncode(str) {
|
||||||
|
const p = Buffer.from(str);
|
||||||
|
let h;
|
||||||
|
if (p.length < 126) {
|
||||||
|
h = Buffer.from([0x81, p.length]);
|
||||||
|
} else {
|
||||||
|
h = Buffer.alloc(4);
|
||||||
|
h[0] = 0x81;
|
||||||
|
h[1] = 126;
|
||||||
|
h.writeUInt16BE(p.length, 2);
|
||||||
|
}
|
||||||
|
return Buffer.concat([h, p]);
|
||||||
|
}
|
||||||
|
|
||||||
|
server.on('upgrade', (req, socket) => {
|
||||||
|
if (new URL(req.url, 'http://x').pathname !== '/stagedisplay') return void socket.destroy();
|
||||||
|
const key = req.headers['sec-websocket-key'];
|
||||||
|
if (!key) return void socket.destroy();
|
||||||
|
console.log('[pp-timers-mock] stage display client connected');
|
||||||
|
const accept = cryptoMod.createHash('sha1').update(key + WS_MAGIC).digest('base64');
|
||||||
|
socket.write(
|
||||||
|
'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' +
|
||||||
|
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`);
|
||||||
|
|
||||||
|
const send = (obj) => {
|
||||||
|
try { socket.write(wsEncode(JSON.stringify(obj))); } catch { /* gone */ }
|
||||||
|
};
|
||||||
|
let authed = false;
|
||||||
|
let buf = Buffer.alloc(0);
|
||||||
|
let lastLtcText = '';
|
||||||
|
|
||||||
|
const sysTimer = setInterval(() => {
|
||||||
|
if (authed) send({ acn: 'sys', txt: ' ' + new Date().toLocaleTimeString() });
|
||||||
|
}, 1000);
|
||||||
|
const tmrTimer = setInterval(() => {
|
||||||
|
if (!authed) return;
|
||||||
|
// noise a client must filter out: an ordinary timer's text
|
||||||
|
send({ acn: 'tmr', uid: 'T1-SERMON', txt: hms(Math.max(0, 25 * 60 - elapsedSec())) });
|
||||||
|
}, 1000);
|
||||||
|
const ltcTimer = setInterval(() => {
|
||||||
|
if (!authed || NO_LTC_FIELD) return;
|
||||||
|
const tc = timecodeStatus();
|
||||||
|
// During no-signal the on-stage field is frozen: no updates at all —
|
||||||
|
// exactly what the module's staleness heuristic must catch.
|
||||||
|
if (!tc.receiving || tc.time === lastLtcText) return;
|
||||||
|
lastLtcText = tc.time;
|
||||||
|
send({ acn: 'tmr', uid: 'LTC-FIELD', txt: tc.time });
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
buf = Buffer.concat([buf, chunk]);
|
||||||
|
while (buf.length >= 2) {
|
||||||
|
const opcode = buf[0] & 0x0f;
|
||||||
|
const masked = (buf[1] & 0x80) !== 0;
|
||||||
|
let len = buf[1] & 0x7f;
|
||||||
|
let off = 2;
|
||||||
|
if (len === 126) {
|
||||||
|
if (buf.length < 4) return;
|
||||||
|
len = buf.readUInt16BE(2);
|
||||||
|
off = 4;
|
||||||
|
} else if (len === 127) {
|
||||||
|
if (buf.length < 10) return;
|
||||||
|
len = Number(buf.readBigUInt64BE(2));
|
||||||
|
off = 10;
|
||||||
|
}
|
||||||
|
const maskLen = masked ? 4 : 0;
|
||||||
|
if (buf.length < off + maskLen + len) return;
|
||||||
|
let payload = buf.slice(off + maskLen, off + maskLen + len);
|
||||||
|
if (masked) {
|
||||||
|
const mask = buf.slice(off, off + 4);
|
||||||
|
payload = Buffer.from(payload);
|
||||||
|
for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4];
|
||||||
|
}
|
||||||
|
buf = buf.slice(off + maskLen + len);
|
||||||
|
|
||||||
|
if (opcode === 0x8) return void socket.destroy();
|
||||||
|
if (opcode === 0x9) { socket.write(Buffer.concat([Buffer.from([0x8a, payload.length]), payload])); continue; }
|
||||||
|
if (opcode !== 0x1) continue;
|
||||||
|
let msg;
|
||||||
|
try { msg = JSON.parse(payload.toString()); } catch { continue; }
|
||||||
|
console.log('[pp-timers-mock] stage <-', JSON.stringify(msg).slice(0, 120));
|
||||||
|
if (msg.acn === 'ath') {
|
||||||
|
if (STAGE_PWD && String(msg.pwd || '') !== STAGE_PWD) {
|
||||||
|
send({ acn: 'ath', ath: false, majorVersion: 7, minorVersion: 21, err: 'Your Password is incorrect' });
|
||||||
|
} else {
|
||||||
|
authed = true;
|
||||||
|
send({ acn: 'ath', ath: true, err: '' });
|
||||||
|
}
|
||||||
|
} else if (msg.acn === 'psl') {
|
||||||
|
send({ acn: 'psl', uid: 'MOCK-LAYOUT-1' });
|
||||||
|
} else if (msg.acn === 'asl') {
|
||||||
|
send({ acn: 'asl', ary: [STAGE_LAYOUT] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearInterval(sysTimer);
|
||||||
|
clearInterval(tmrTimer);
|
||||||
|
clearInterval(ltcTimer);
|
||||||
|
};
|
||||||
|
socket.on('close', cleanup);
|
||||||
|
socket.on('error', () => { cleanup(); socket.destroy(); });
|
||||||
|
});
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`[pp-timers-mock] listening on http://127.0.0.1:${PORT}${NO_LTC ? ' (timecode endpoint disabled)' : ''}`);
|
console.log(`[pp-timers-mock] listening on http://127.0.0.1:${PORT}${NO_LTC ? ' (timecode endpoint disabled)' : ''}${NO_LTC_FIELD ? ' (stage layout has no LTC field)' : ''}${STAGE_PWD ? ' (stage password required)' : ''}`);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user