Compare commits
@@ -10,3 +10,7 @@ config/layouts/
|
|||||||
|
|
||||||
# compiled per-machine, source sits next to it
|
# compiled per-machine, source sits next to it
|
||||||
modules/propresenter-timers/ltc-capture
|
modules/propresenter-timers/ltc-capture
|
||||||
|
|
||||||
|
# the launcher's build output, and its cache of the Node runtime it embeds
|
||||||
|
launcher/macos/build/
|
||||||
|
launcher/macos/.cache/
|
||||||
|
|||||||
@@ -3,11 +3,15 @@
|
|||||||
Modular production dashboard web app for Waters Church, developed in this repo.
|
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/`. Runtime state (module config, named layouts)
|
`/admin`, modules in `modules/`, and a native macOS menu-bar launcher in
|
||||||
is written to a per-machine data directory outside the checkout
|
`launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
|
||||||
(`PRODDASH_DATA_DIR` overrides; see README "Where settings are kept") — never
|
the server and its own Node runtime, supervises them, and owns the macOS
|
||||||
add code that writes runtime state into the repo folder. [SPEC.md](SPEC.md) was the authority for the
|
permissions modules declare; see its README). Runtime state (module
|
||||||
initial build; [README.md](README.md) has run/configure/use instructions.
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -29,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
|
||||||
@@ -67,13 +88,16 @@ it at startup (`Settings : …`).
|
|||||||
|
|
||||||
The directory holds `modules.json` (module config), `layouts/` (named
|
The directory holds `modules.json` (module config), `layouts/` (named
|
||||||
layouts) and `modules/` (modules installed from the admin page), all written
|
layouts) and `modules/` (modules installed from the admin page), all written
|
||||||
by the app itself. It may also hold a `proddash.json`
|
by the app itself. Installed from the macOS app, it also holds `app/` — the
|
||||||
|
copy of ProdDash that actually runs, laid down from inside the app bundle on
|
||||||
|
first launch and updated in place from then on (nothing may write inside a
|
||||||
|
signed .app, and ProdDash updates itself). It may also hold a `proddash.json`
|
||||||
with this machine's shell settings — the same keys as the checked-in
|
with this machine's shell settings — the same keys as the checked-in
|
||||||
[config/proddash.json](config/proddash.json), which supplies the defaults:
|
[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. |
|
||||||
|
|
||||||
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
|
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
|
||||||
@@ -168,8 +192,10 @@ below. Nothing is installed without someone clicking.
|
|||||||
|
|
||||||
- **ProdCom** — live transcript stream with in-place updates of in-progress
|
- **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
|
||||||
|
and jump-to-latest in the title bar.
|
||||||
Admin config: ProdCom server (host : port), API key.
|
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: ProPresenter endpoint (host : port), password.
|
Admin config: ProPresenter endpoint (host : port), password.
|
||||||
@@ -194,14 +220,28 @@ below. Nothing is installed without someone clicking.
|
|||||||
session (Start ProdDash.command) and approve the one-time prompt; a server
|
session (Start ProdDash.command) and approve the one-time prompt; a server
|
||||||
started over SSH or by bare launchd silently captures zeros. (Engine
|
started over SSH or by bare launchd silently captures zeros. (Engine
|
||||||
self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
|
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
|
||||||
|
|||||||
+87
-3
@@ -66,6 +66,9 @@ the app); its settings are kept for a later reinstall.
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"proddash": ">=1.1.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",
|
||||||
@@ -88,6 +91,7 @@ the app); its settings are kept for a later reinstall.
|
|||||||
| `version` | yes | Your module's version (`x.y.z`). Bump it on every change — the admin page offers updates when the repo's copy is newer than the installed one. |
|
| `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. |
|
| `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. |
|
| `description` | no | Shown in the admin page / picker. |
|
||||||
|
| `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. |
|
||||||
| `client` | yes | Client entry file, loaded as an ES module. |
|
| `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. |
|
||||||
@@ -99,24 +103,56 @@ the app); its settings are kept for a later reinstall.
|
|||||||
Both schemas map field names to specs:
|
Both schemas map field names to specs:
|
||||||
|
|
||||||
```
|
```
|
||||||
{ "type": "string" | "number" | "boolean" | "switch" | "select" | "password" | "endpoint",
|
{ "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>,
|
||||||
"group": "Section heading", // optional; see below
|
"group": "Section heading", // optional; see below
|
||||||
"showWhen": "<other boolean/switch key>", // optional; see below
|
"showWhen": "<other boolean/switch key>", // optional; see below
|
||||||
"options": [ { "value": "a", "label": "A" }, … ], // "select" only
|
"help": "One line shown under the control", // optional, admin config only
|
||||||
"optionsRoute": "/devices" // "select" only, 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` renders as a checkbox; `switch` is the same true/false value shown
|
`boolean` renders as a checkbox; `switch` is the same true/false value shown
|
||||||
as a toggle (use it for a feature on/off that other fields depend on).
|
as a toggle (use it for a feature on/off that other fields depend on).
|
||||||
|
`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
|
`group` gathers consecutive fields under a labeled subsection in the admin
|
||||||
page, so a module with several distinct concerns (e.g. a ProPresenter
|
page, so a module with several distinct concerns (e.g. a ProPresenter
|
||||||
connection and an LTC audio input) reads as clearly separated blocks. Fields
|
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.
|
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`: a line of guidance at the top of the group.
|
||||||
|
|
||||||
|
`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
|
`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
|
`<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.
|
listener switch is enabled. The hidden field still keeps its stored value.
|
||||||
@@ -129,6 +165,13 @@ refresh of — static `options`: the admin page GETs
|
|||||||
stays selectable even when the route is down or the option has vanished. (See
|
stays selectable even when the route is down or the option has vanished. (See
|
||||||
`propresenter-timers`' "Audio device" for a worked example.)
|
`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 the server's `modules.json` (in the per-machine data directory, see the
|
in the server's `modules.json` (in the per-machine data directory, see the
|
||||||
README), handed to your *server* entry, and **never sent to 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
|
||||||
@@ -168,6 +211,38 @@ Admin config lives in `modules.json` inside the server's data directory
|
|||||||
survives updates of the app folder). Per-tile settings live inside each
|
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
|
||||||
@@ -522,6 +597,15 @@ client-side failures (a tile that fails to start says so in the tile).
|
|||||||
a timecode API). Also serves the stage-display websocket with an
|
a timecode API). Also serves the stage-display websocket with an
|
||||||
LTC-labeled layout field (`--stage-pwd`, `--no-ltc-field` exercise its
|
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`.
|
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
|
||||||
@@ -0,0 +1,625 @@
|
|||||||
|
/* 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderClock() {
|
||||||
|
const plan = state?.plan;
|
||||||
|
const live = state?.live || {};
|
||||||
|
clockLabelEl.textContent = '';
|
||||||
|
clockValueEl.textContent = '';
|
||||||
|
clockValueEl.className = 'pp-clock-value';
|
||||||
|
if (!plan || headEl.hidden) return;
|
||||||
|
const now = serverNow();
|
||||||
|
if (on('showRunningClock') && live.following && live.serviceStartedAt) {
|
||||||
|
clockLabelEl.textContent = 'Running';
|
||||||
|
clockValueEl.textContent = fmtDur((now - live.serviceStartedAt) / 1000);
|
||||||
|
// 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('showCountdown') && plan.serviceStartsAt && plan.serviceStartsAt > now) {
|
||||||
|
clockLabelEl.textContent = 'Starts in';
|
||||||
|
clockValueEl.textContent = fmtDur((plan.serviceStartsAt - now) / 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (on('showRunningClock') && plan.serviceStartsAt) {
|
||||||
|
clockLabelEl.textContent = 'Since start';
|
||||||
|
clockValueEl.textContent = fmtDur((now - plan.serviceStartsAt) / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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,124 @@
|
|||||||
|
{
|
||||||
|
"id": "pco-plan",
|
||||||
|
"name": "PCO Plan",
|
||||||
|
"version": "1.3.0",
|
||||||
|
"proddash": ">=1.3.0",
|
||||||
|
"description": "Order of service from Planning Center Services, following ProPresenter live",
|
||||||
|
"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 (Application ID + Secret), or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configSchema": {
|
||||||
|
"appId": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Application 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 Application ID + Secret)",
|
||||||
|
"default": "",
|
||||||
|
"group": "Planning Center credentials"
|
||||||
|
},
|
||||||
|
"serviceTypeId": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Service type",
|
||||||
|
"default": "",
|
||||||
|
"optionsRoute": "/service-types",
|
||||||
|
"help": "Your Planning Center Services folders appear as groups in this list; pick the service type whose plans the tiles follow. Save the credentials first if the list is empty."
|
||||||
|
},
|
||||||
|
"planId": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Plan",
|
||||||
|
"default": "",
|
||||||
|
"options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }],
|
||||||
|
"optionsRoute": "/plans",
|
||||||
|
"optionsDependsOn": "serviceTypeId",
|
||||||
|
"help": "Leave on Automatic for services. Pin a specific plan for rehearsals or to review a past service."
|
||||||
|
},
|
||||||
|
"pollSeconds": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Re-read the plan every N seconds",
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"followProPresenter": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Follow the ProPresenter module's live item to highlight the current plan item and time it",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instanceGroups": {
|
||||||
|
"Top bar": {
|
||||||
|
"collapsed": false
|
||||||
|
},
|
||||||
|
"Colors": {
|
||||||
|
"collapsed": false,
|
||||||
|
"columns": 2
|
||||||
|
},
|
||||||
|
"Show / hide": {
|
||||||
|
"collapsed": true,
|
||||||
|
"columns": 3,
|
||||||
|
"help": "Untick 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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,913 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PCO Plan — server part.
|
||||||
|
*
|
||||||
|
* Owns the one Planning Center Services connection, keeps the current plan
|
||||||
|
* (order of service) for the configured service type in memory, and fans it
|
||||||
|
* out to every tile over SSE. Browsers never talk to Planning Center.
|
||||||
|
*
|
||||||
|
* GET /state → { state, now } (instant paint for new tiles)
|
||||||
|
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||||||
|
* POST /refresh → re-read the plan now
|
||||||
|
* GET /service-types → { options } for the admin "Service type" select,
|
||||||
|
* grouped by Planning Center folder path
|
||||||
|
* GET /plans?serviceTypeId=… → { options } for the admin "Plan" select
|
||||||
|
*
|
||||||
|
* Planning Center API v2 (https://api.planningcenteronline.com/services/v2):
|
||||||
|
* Auth: Personal Access Token (HTTP Basic app_id:secret) or an OAuth 2.0
|
||||||
|
* bearer token. Responses are JSON:API — `data`, `included`, `links.next`.
|
||||||
|
* GET /folders, /service_types (setup browsing)
|
||||||
|
* GET /service_types/{st}/plans?filter=future|past (pick the plan)
|
||||||
|
* GET /service_types/{st}/plans/{id} (title, dates…)
|
||||||
|
* GET …/plans/{id}/plan_times (service start times)
|
||||||
|
* GET …/plans/{id}/items?include=song,arrangement,item_notes
|
||||||
|
*
|
||||||
|
* Phase 2 — following ProPresenter: instead of talking to ProPresenter a
|
||||||
|
* second time, this module reads the propresenter-now-next module's own SSE
|
||||||
|
* feed over loopback (/api/modules/propresenter-now-next/stream) and matches
|
||||||
|
* the live presentation name to a plan item. Item start/end timestamps are
|
||||||
|
* tracked here, once, so every tile agrees on the runtime. The shell's port
|
||||||
|
* is guessed the way the shell decides it and then confirmed from the Host
|
||||||
|
* header of the first tile request.
|
||||||
|
*
|
||||||
|
* PCO_PLAN_API_BASE=http://127.0.0.1:24700 points the module at
|
||||||
|
* tools/pco-mock.js for development.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
|
||||||
|
const API_BASE = String(process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||||||
|
const SERVICES = '/services/v2';
|
||||||
|
|
||||||
|
/** Abort a hung Planning Center request well before the poll cadence. */
|
||||||
|
const REQUEST_TIMEOUT_MS = 12000;
|
||||||
|
/** Fastest allowed plan re-read (PCO rate limit is 100 requests / 20 s per app). */
|
||||||
|
const MIN_POLL_S = 10;
|
||||||
|
/** Backoff after a failed plan read. */
|
||||||
|
const RETRY_MS = 15000;
|
||||||
|
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||||||
|
const HEARTBEAT_MS = 15000;
|
||||||
|
/** The Now/Next module whose feed we follow. */
|
||||||
|
const NOW_NEXT_ID = 'propresenter-now-next';
|
||||||
|
const NOW_NEXT_RETRY_MS = 3000;
|
||||||
|
const NOW_NEXT_MISSING_RETRY_MS = 15000;
|
||||||
|
/** Minimum name-similarity to call a ProPresenter document a plan item. */
|
||||||
|
const MATCH_THRESHOLD = 0.6;
|
||||||
|
|
||||||
|
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
||||||
|
let current = null;
|
||||||
|
|
||||||
|
/* ── small helpers ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function clearedState() {
|
||||||
|
return {
|
||||||
|
configured: false, // credentials present
|
||||||
|
serviceTypeSet: false, // a service type is chosen
|
||||||
|
reachable: false, // last Planning Center read succeeded
|
||||||
|
lastError: '',
|
||||||
|
lastFetched: 0,
|
||||||
|
apiBase: API_BASE,
|
||||||
|
serviceType: { id: '', name: '' },
|
||||||
|
plan: null,
|
||||||
|
items: [],
|
||||||
|
live: clearedLive(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearedLive() {
|
||||||
|
return {
|
||||||
|
following: false, // admin switch on
|
||||||
|
available: false, // Now/Next feed reachable
|
||||||
|
reason: '', // why not available
|
||||||
|
ppEnabled: false,
|
||||||
|
ppReachable: false,
|
||||||
|
activeName: '', // what ProPresenter has live right now
|
||||||
|
matched: false, // activeName maps to a plan item
|
||||||
|
currentItemId: '',
|
||||||
|
currentScore: 0,
|
||||||
|
serviceStartedAt: 0, // first matched item start, ms epoch
|
||||||
|
history: {}, // itemId → { startedAt, endedAt }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First guess at the shell's port, so the loopback Now/Next feed lands on this
|
||||||
|
* ProdDash before any browser has talked to us. Mirrors how the shell decides:
|
||||||
|
* PORT env, then this machine's proddash.json in the data directory, then the
|
||||||
|
* checked-in config/proddash.json, then 24500. The guess is corrected from the
|
||||||
|
* Host header of the first request that reaches a route (see learnPort).
|
||||||
|
*/
|
||||||
|
function guessShellPort() {
|
||||||
|
const env = Number(process.env.PORT);
|
||||||
|
if (env) return env;
|
||||||
|
const home = os.homedir();
|
||||||
|
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
|
||||||
|
const root = path.join(__dirname, '..', '..');
|
||||||
|
const dataDir = fromEnv
|
||||||
|
? path.resolve(root, fromEnv)
|
||||||
|
: process.platform === 'win32'
|
||||||
|
? path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash')
|
||||||
|
: process.platform === 'darwin'
|
||||||
|
? path.join(home, 'Library', 'Application Support', 'ProdDash')
|
||||||
|
: path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash');
|
||||||
|
for (const file of [path.join(dataDir, 'proddash.json'), path.join(root, 'config', 'proddash.json')]) {
|
||||||
|
try {
|
||||||
|
const port = Number(JSON.parse(fs.readFileSync(file, 'utf8')).port);
|
||||||
|
if (port) return port;
|
||||||
|
} catch { /* try the next one */ }
|
||||||
|
}
|
||||||
|
return 24500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Port a browser used to reach us, from a Host header ("10.3.8.41:24500"). */
|
||||||
|
function portFromHost(hostHeader) {
|
||||||
|
const m = /:(\d+)$/.exec(String(hostHeader || '').trim());
|
||||||
|
if (m) return Number(m[1]) || 0;
|
||||||
|
return hostHeader ? 80 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localYmd(d) {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Planning Center client ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function createPcoClient(config) {
|
||||||
|
const token = String(config.accessToken || '').trim();
|
||||||
|
const appId = String(config.appId || '').trim();
|
||||||
|
const secret = String(config.secret || '');
|
||||||
|
let auth = '';
|
||||||
|
if (token) auth = `Bearer ${token}`;
|
||||||
|
else if (appId && secret) auth = 'Basic ' + Buffer.from(`${appId}:${secret}`).toString('base64');
|
||||||
|
|
||||||
|
async function get(target, params) {
|
||||||
|
const url = new URL(/^https?:\/\//.test(target) ? target : API_BASE + target);
|
||||||
|
for (const [k, v] of Object.entries(params || {})) url.searchParams.set(k, String(v));
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
headers: { Authorization: auth, Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const e = new Error(`Planning Center unreachable: ${err?.message || err}`);
|
||||||
|
e.network = true;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
let detail = '';
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
detail = body?.errors?.[0]?.detail || body?.errors?.[0]?.title || '';
|
||||||
|
} catch { /* no JSON body */ }
|
||||||
|
const e = new Error(
|
||||||
|
response.status === 401
|
||||||
|
? 'Planning Center rejected the credentials (401)'
|
||||||
|
: `Planning Center ${response.status}${detail ? `: ${detail}` : ''}`
|
||||||
|
);
|
||||||
|
e.status = response.status;
|
||||||
|
if (response.status === 429) e.retryAfter = Number(response.headers.get('retry-after')) || 10;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Follow JSON:API `links.next` until the collection is complete. */
|
||||||
|
async function getAll(target, params) {
|
||||||
|
const data = [];
|
||||||
|
const included = [];
|
||||||
|
let url = target;
|
||||||
|
let query = { per_page: 100, ...(params || {}) };
|
||||||
|
for (let page = 0; page < 30; page += 1) {
|
||||||
|
const body = await get(url, query);
|
||||||
|
if (Array.isArray(body?.data)) data.push(...body.data);
|
||||||
|
if (Array.isArray(body?.included)) included.push(...body.included);
|
||||||
|
const next = body?.links?.next;
|
||||||
|
if (!next) break;
|
||||||
|
url = next;
|
||||||
|
query = {}; // `next` already carries the query string
|
||||||
|
}
|
||||||
|
return { data, included };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { configured: Boolean(auth), method: token ? 'oauth' : 'pat', get, getAll };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── plan shaping ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function pickServiceTime(planTimes, nowMs) {
|
||||||
|
const services = planTimes.filter((t) => t.timeType === 'service' && t.startsAt);
|
||||||
|
const pool = services.length ? services : planTimes.filter((t) => t.startsAt);
|
||||||
|
if (!pool.length) return null;
|
||||||
|
pool.sort((a, b) => a.startsAt - b.startsAt);
|
||||||
|
// The service that is running or up next; after the last one, the last one.
|
||||||
|
const upcoming = pool.find((t) => (t.endsAt || t.startsAt + 90 * 60000) > nowMs);
|
||||||
|
return upcoming || pool[pool.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scheduled start of every item from the chosen service start time. */
|
||||||
|
function scheduleItems(items, serviceStartMs) {
|
||||||
|
for (const item of items) item.startsAt = 0;
|
||||||
|
if (!serviceStartMs) return;
|
||||||
|
let t = serviceStartMs;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.servicePosition !== 'during') continue;
|
||||||
|
item.startsAt = t;
|
||||||
|
t += (item.length || 0) * 1000;
|
||||||
|
}
|
||||||
|
// Post-service items run on from the end of the service…
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.servicePosition !== 'post') continue;
|
||||||
|
item.startsAt = t;
|
||||||
|
t += (item.length || 0) * 1000;
|
||||||
|
}
|
||||||
|
// …and pre-service items are counted backwards to end at the service start.
|
||||||
|
let back = serviceStartMs;
|
||||||
|
for (let i = items.length - 1; i >= 0; i -= 1) {
|
||||||
|
const item = items[i];
|
||||||
|
if (item.servicePosition !== 'pre') continue;
|
||||||
|
back -= (item.length || 0) * 1000;
|
||||||
|
item.startsAt = back;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeItems(itemsBody) {
|
||||||
|
const included = new Map();
|
||||||
|
for (const rec of itemsBody.included || []) included.set(`${rec.type}/${rec.id}`, rec);
|
||||||
|
const rel = (item, name) => item?.relationships?.[name]?.data;
|
||||||
|
const items = (itemsBody.data || []).map((it) => {
|
||||||
|
const a = it.attributes || {};
|
||||||
|
const songRef = rel(it, 'song');
|
||||||
|
const song = songRef ? included.get(`Song/${songRef.id}`) : null;
|
||||||
|
const arrRef = rel(it, 'arrangement');
|
||||||
|
const arrangement = arrRef ? included.get(`Arrangement/${arrRef.id}`) : null;
|
||||||
|
const noteRefs = Array.isArray(rel(it, 'item_notes')) ? rel(it, 'item_notes') : [];
|
||||||
|
const notes = noteRefs
|
||||||
|
.map((ref) => included.get(`ItemNote/${ref.id}`))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((n) => ({
|
||||||
|
category: String(n.attributes?.category_name || ''),
|
||||||
|
content: String(n.attributes?.content || '').trim(),
|
||||||
|
}))
|
||||||
|
.filter((n) => n.content);
|
||||||
|
return {
|
||||||
|
id: String(it.id),
|
||||||
|
sequence: Number(a.sequence) || 0,
|
||||||
|
title: String(a.title || '').trim(),
|
||||||
|
itemType: String(a.item_type || 'item'),
|
||||||
|
length: Number(a.length) || 0,
|
||||||
|
servicePosition: String(a.service_position || 'during'),
|
||||||
|
description: String(a.description || '').trim(),
|
||||||
|
keyName: String(a.key_name || '').trim(),
|
||||||
|
song: song
|
||||||
|
? {
|
||||||
|
title: String(song.attributes?.title || ''),
|
||||||
|
author: String(song.attributes?.author || ''),
|
||||||
|
ccli: song.attributes?.ccli_number ? String(song.attributes.ccli_number) : '',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
arrangement: arrangement ? String(arrangement.attributes?.name || '') : '',
|
||||||
|
notes,
|
||||||
|
startsAt: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
items.sort((x, y) => x.sequence - y.sequence);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapePlan(planRec, serviceTypeId, planTimes) {
|
||||||
|
const a = planRec?.attributes || {};
|
||||||
|
return {
|
||||||
|
id: String(planRec?.id || ''),
|
||||||
|
serviceTypeId: String(serviceTypeId),
|
||||||
|
title: String(a.title || '').trim(),
|
||||||
|
seriesTitle: String(a.series_title || '').trim(),
|
||||||
|
dates: String(a.dates || '').trim(),
|
||||||
|
shortDates: String(a.short_dates || '').trim(),
|
||||||
|
sortDate: a.sort_date ? Date.parse(a.sort_date) || 0 : 0,
|
||||||
|
totalLength: Number(a.total_length) || 0,
|
||||||
|
itemsCount: Number(a.items_count) || 0,
|
||||||
|
url: String(a.planning_center_url || ''),
|
||||||
|
planTimes,
|
||||||
|
serviceStartsAt: 0,
|
||||||
|
serviceEndsAt: 0,
|
||||||
|
serviceName: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the module ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init({ config, log }) {
|
||||||
|
const streams = new Set();
|
||||||
|
const pco = createPcoClient(config);
|
||||||
|
const serviceTypeId = String(config.serviceTypeId || '').trim();
|
||||||
|
const pinnedPlanId = String(config.planId || '').trim();
|
||||||
|
const pollMs = Math.max(MIN_POLL_S, Number(config.pollSeconds) || 30) * 1000;
|
||||||
|
const follow = config.followProPresenter !== false;
|
||||||
|
|
||||||
|
let state = clearedState();
|
||||||
|
state.configured = pco.configured;
|
||||||
|
state.serviceTypeSet = Boolean(serviceTypeId);
|
||||||
|
state.serviceType = { id: serviceTypeId, name: '' };
|
||||||
|
state.live.following = follow;
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
let pollTimer = null;
|
||||||
|
let inFlight = null;
|
||||||
|
let lastFrame = '';
|
||||||
|
|
||||||
|
/* — broadcasting — */
|
||||||
|
|
||||||
|
function frameOf() {
|
||||||
|
return JSON.stringify(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(force) {
|
||||||
|
const frame = frameOf();
|
||||||
|
if (!force && frame === lastFrame) return;
|
||||||
|
lastFrame = frame;
|
||||||
|
if (!streams.size) return;
|
||||||
|
const payload = `event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`;
|
||||||
|
for (const res of streams) {
|
||||||
|
try {
|
||||||
|
res.write(payload);
|
||||||
|
} catch {
|
||||||
|
streams.delete(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — plan reading — */
|
||||||
|
|
||||||
|
function planPath(planId) {
|
||||||
|
return `${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(planId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The plans around now for a service type: a few upcoming, a few recent, oldest first. */
|
||||||
|
async function listPlans(stId, { future = 5, past = 3 } = {}) {
|
||||||
|
const base = `${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`;
|
||||||
|
const [fut, pst] = await Promise.all([
|
||||||
|
pco.get(base, { filter: 'future', order: 'sort_date', per_page: future }),
|
||||||
|
pco.get(base, { filter: 'past', order: '-sort_date', per_page: past }),
|
||||||
|
]);
|
||||||
|
const byId = new Map();
|
||||||
|
for (const rec of [...(fut?.data || []), ...(pst?.data || [])]) {
|
||||||
|
const sortDate = Date.parse(rec?.attributes?.sort_date || '');
|
||||||
|
if (!rec?.id || !Number.isFinite(sortDate)) continue;
|
||||||
|
byId.set(String(rec.id), { rec, sortDate });
|
||||||
|
}
|
||||||
|
return Array.from(byId.values()).sort((x, y) => x.sortDate - y.sortDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today's plan, else the next upcoming one, else the most recent past one. */
|
||||||
|
function pickPlan(plans) {
|
||||||
|
if (!plans.length) return null;
|
||||||
|
const now = Date.now();
|
||||||
|
const today = localYmd(new Date(now));
|
||||||
|
const todays = plans.find((p) => localYmd(new Date(p.sortDate)) === today);
|
||||||
|
if (todays) return todays.rec;
|
||||||
|
const next = plans.find((p) => p.sortDate > now);
|
||||||
|
if (next) return next.rec;
|
||||||
|
return plans[plans.length - 1].rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function choosePlanId(stId) {
|
||||||
|
return pickPlan(await listPlans(stId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPlanTimes(planId) {
|
||||||
|
const body = await pco.getAll(`${planPath(planId)}/plan_times`);
|
||||||
|
return (body.data || [])
|
||||||
|
.map((t) => ({
|
||||||
|
id: String(t.id),
|
||||||
|
name: String(t.attributes?.name || '').trim(),
|
||||||
|
timeType: String(t.attributes?.time_type || 'service'),
|
||||||
|
startsAt: Date.parse(t.attributes?.starts_at || '') || 0,
|
||||||
|
endsAt: Date.parse(t.attributes?.ends_at || '') || 0,
|
||||||
|
}))
|
||||||
|
.filter((t) => t.startsAt)
|
||||||
|
.sort((x, y) => x.startsAt - y.startsAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readItems(planId) {
|
||||||
|
const target = `${planPath(planId)}/items`;
|
||||||
|
try {
|
||||||
|
return shapeItems(await pco.getAll(target, { include: 'song,arrangement,item_notes' }));
|
||||||
|
} catch (err) {
|
||||||
|
// An account/API that refuses one of the includes still has items.
|
||||||
|
if (err.status && err.status >= 400 && err.status < 500 && err.status !== 401 && err.status !== 429) {
|
||||||
|
log(`items include rejected (${err.message}) — reading items without includes`);
|
||||||
|
return shapeItems(await pco.getAll(target));
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPlan() {
|
||||||
|
if (!pco.configured || !serviceTypeId) return;
|
||||||
|
let planRec = null;
|
||||||
|
if (pinnedPlanId) {
|
||||||
|
planRec = (await pco.get(planPath(pinnedPlanId)))?.data || null;
|
||||||
|
} else {
|
||||||
|
const chosen = await choosePlanId(serviceTypeId);
|
||||||
|
// Re-read the chosen plan by id: the list endpoint's attributes are the
|
||||||
|
// same, but this keeps one code path and picks up edits immediately.
|
||||||
|
planRec = chosen ? (await pco.get(planPath(chosen.id)))?.data || chosen : null;
|
||||||
|
}
|
||||||
|
if (!state.serviceType.name) {
|
||||||
|
try {
|
||||||
|
const st = await pco.get(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}`);
|
||||||
|
state.serviceType = { id: serviceTypeId, name: String(st?.data?.attributes?.name || '') };
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 404) throw new Error(`Service type ${serviceTypeId} does not exist — pick another in the setup page`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!planRec) {
|
||||||
|
state.plan = null;
|
||||||
|
state.items = [];
|
||||||
|
state.reachable = true;
|
||||||
|
state.lastError = '';
|
||||||
|
state.lastFetched = Date.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [planTimes, items] = await Promise.all([readPlanTimes(planRec.id), readItems(planRec.id)]);
|
||||||
|
const plan = shapePlan(planRec, serviceTypeId, planTimes);
|
||||||
|
const service = pickServiceTime(planTimes, Date.now());
|
||||||
|
if (service) {
|
||||||
|
plan.serviceStartsAt = service.startsAt;
|
||||||
|
plan.serviceEndsAt = service.endsAt;
|
||||||
|
plan.serviceName = service.name;
|
||||||
|
}
|
||||||
|
scheduleItems(items, plan.serviceStartsAt || plan.sortDate);
|
||||||
|
if (state.plan?.id !== plan.id) {
|
||||||
|
if (state.plan) log(`plan changed: ${state.plan.id} → ${plan.id} (${plan.dates} ${plan.title})`);
|
||||||
|
else log(`following plan ${plan.id}: ${plan.dates} ${plan.title || ''}`.trim());
|
||||||
|
resetLiveTracking();
|
||||||
|
}
|
||||||
|
state.plan = plan;
|
||||||
|
state.items = items;
|
||||||
|
state.reachable = true;
|
||||||
|
state.lastError = '';
|
||||||
|
state.lastFetched = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePoll(ms) {
|
||||||
|
if (stopped) return;
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
pollTimer = setTimeout(poll, ms);
|
||||||
|
pollTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
if (stopped) return;
|
||||||
|
if (inFlight) return inFlight;
|
||||||
|
inFlight = (async () => {
|
||||||
|
let delay = pollMs;
|
||||||
|
try {
|
||||||
|
await readPlan();
|
||||||
|
} catch (err) {
|
||||||
|
state.reachable = false;
|
||||||
|
state.lastError = err?.message || String(err);
|
||||||
|
delay = err?.retryAfter ? (err.retryAfter + 1) * 1000 : RETRY_MS;
|
||||||
|
log(`plan read failed: ${state.lastError}`);
|
||||||
|
}
|
||||||
|
broadcast();
|
||||||
|
schedulePoll(delay);
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
await inFlight;
|
||||||
|
} finally {
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — phase 2: follow the ProPresenter Now/Next feed over loopback — */
|
||||||
|
|
||||||
|
let nnRequest = null;
|
||||||
|
let nnRetryTimer = null;
|
||||||
|
let lastLiveName = null;
|
||||||
|
const guessedPort = guessShellPort();
|
||||||
|
let nnPort = guessedPort;
|
||||||
|
/** Learned ports that refused the loopback connection (a reverse proxy's port, say). */
|
||||||
|
const badPorts = new Set();
|
||||||
|
|
||||||
|
/** A browser reached us on this Host — normally the port the shell really serves on. */
|
||||||
|
function learnPort(hostHeader) {
|
||||||
|
const port = portFromHost(hostHeader);
|
||||||
|
if (!port || port === nnPort || badPorts.has(port)) return;
|
||||||
|
log(`ProdDash is on port ${port} (guessed ${nnPort}) — reconnecting the ProPresenter feed there`);
|
||||||
|
nnPort = port;
|
||||||
|
if (!follow || stopped) return;
|
||||||
|
clearTimeout(nnRetryTimer);
|
||||||
|
try { nnRequest?.destroy(); } catch { /* gone */ }
|
||||||
|
nnRequest = null;
|
||||||
|
connectNowNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLiveTracking() {
|
||||||
|
const keep = state.live;
|
||||||
|
state.live = {
|
||||||
|
...clearedLive(),
|
||||||
|
following: keep.following,
|
||||||
|
available: keep.available,
|
||||||
|
reason: keep.reason,
|
||||||
|
ppEnabled: keep.ppEnabled,
|
||||||
|
ppReachable: keep.ppReachable,
|
||||||
|
activeName: keep.activeName,
|
||||||
|
};
|
||||||
|
lastLiveName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best plan item for a live presentation name, preferring the ones ahead of us. */
|
||||||
|
function matchItem(name) {
|
||||||
|
const items = state.items.filter((it) => it.itemType !== 'header');
|
||||||
|
if (!items.length) return null;
|
||||||
|
const currentIdx = items.findIndex((it) => it.id === state.live.currentItemId);
|
||||||
|
let best = null;
|
||||||
|
for (let i = 0; i < items.length; i += 1) {
|
||||||
|
const item = items[i];
|
||||||
|
const score = Math.max(similarity(name, item.title), item.song ? similarity(name, item.song.title) : 0);
|
||||||
|
if (score < MATCH_THRESHOLD) continue;
|
||||||
|
// Distance ahead of the current item (wrapping so items behind rank last).
|
||||||
|
const dist = currentIdx < 0 ? i : (i - currentIdx + items.length) % items.length;
|
||||||
|
if (
|
||||||
|
!best
|
||||||
|
|| score > best.score + 0.001
|
||||||
|
|| (Math.abs(score - best.score) <= 0.001 && dist < best.dist)
|
||||||
|
) {
|
||||||
|
best = { item, score, dist };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLiveView(view) {
|
||||||
|
const live = state.live;
|
||||||
|
live.available = true;
|
||||||
|
live.reason = '';
|
||||||
|
live.ppEnabled = Boolean(view?.enabled);
|
||||||
|
live.ppReachable = Boolean(view?.reachable);
|
||||||
|
const activeItem = Array.isArray(view?.items) && view.activeIndex >= 0 ? view.items[view.activeIndex] : null;
|
||||||
|
const name = String(activeItem?.name || view?.activePresentationName || '').trim();
|
||||||
|
live.activeName = name;
|
||||||
|
if (!name) {
|
||||||
|
// Cleared output between items is normal — the current item stays current.
|
||||||
|
lastLiveName = '';
|
||||||
|
broadcast();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (name === lastLiveName) return;
|
||||||
|
lastLiveName = name;
|
||||||
|
const hit = matchItem(name);
|
||||||
|
live.matched = Boolean(hit);
|
||||||
|
if (hit) {
|
||||||
|
live.currentScore = hit.score;
|
||||||
|
if (hit.item.id !== live.currentItemId) startItem(hit.item.id);
|
||||||
|
}
|
||||||
|
broadcast();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startItem(itemId) {
|
||||||
|
const live = state.live;
|
||||||
|
const now = Date.now();
|
||||||
|
if (live.currentItemId && live.history[live.currentItemId] && !live.history[live.currentItemId].endedAt) {
|
||||||
|
live.history[live.currentItemId].endedAt = now;
|
||||||
|
}
|
||||||
|
live.currentItemId = itemId;
|
||||||
|
live.history[itemId] = { startedAt: now, endedAt: 0 };
|
||||||
|
if (!live.serviceStartedAt) live.serviceStartedAt = now;
|
||||||
|
const item = state.items.find((it) => it.id === itemId);
|
||||||
|
log(`live item → ${item?.title || itemId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowNextUnavailable(reason, retryMs) {
|
||||||
|
const live = state.live;
|
||||||
|
if (live.available || live.reason !== reason) {
|
||||||
|
live.available = false;
|
||||||
|
live.reason = reason;
|
||||||
|
live.ppReachable = false;
|
||||||
|
broadcast();
|
||||||
|
}
|
||||||
|
if (stopped) return;
|
||||||
|
clearTimeout(nnRetryTimer);
|
||||||
|
nnRetryTimer = setTimeout(connectNowNext, retryMs);
|
||||||
|
nnRetryTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectNowNext() {
|
||||||
|
if (stopped || !follow) return;
|
||||||
|
const port = nnPort;
|
||||||
|
const req = http.get(
|
||||||
|
{ host: '127.0.0.1', port, path: `/api/modules/${NOW_NEXT_ID}/stream`, headers: { Accept: 'text/event-stream' } },
|
||||||
|
(res) => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
res.resume();
|
||||||
|
const why = res.statusCode === 404
|
||||||
|
? 'ProPresenter module is disabled or not installed'
|
||||||
|
: `ProPresenter module feed answered ${res.statusCode}`;
|
||||||
|
return nowNextUnavailable(why, NOW_NEXT_MISSING_RETRY_MS);
|
||||||
|
}
|
||||||
|
res.setEncoding('utf8');
|
||||||
|
let buffer = '';
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
buffer += chunk;
|
||||||
|
let idx;
|
||||||
|
while ((idx = buffer.indexOf('\n\n')) >= 0) {
|
||||||
|
const block = buffer.slice(0, idx);
|
||||||
|
buffer = buffer.slice(idx + 2);
|
||||||
|
let event = 'message';
|
||||||
|
let data = '';
|
||||||
|
for (const line of block.split('\n')) {
|
||||||
|
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||||
|
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
||||||
|
}
|
||||||
|
if (event !== 'state' || !data) continue;
|
||||||
|
try {
|
||||||
|
applyLiveView(JSON.parse(data));
|
||||||
|
} catch { /* a bad frame is not fatal */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.on('end', () => nowNextUnavailable('ProPresenter module feed ended — reconnecting', NOW_NEXT_RETRY_MS));
|
||||||
|
res.on('error', () => nowNextUnavailable('ProPresenter module feed dropped — reconnecting', NOW_NEXT_RETRY_MS));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on('error', (err) => {
|
||||||
|
if (req !== nnRequest) return; // superseded by a reconnect on the learned port
|
||||||
|
if (port !== guessedPort) {
|
||||||
|
// The Host header lied (reverse proxy, port forward) — that port is
|
||||||
|
// not this ProdDash. Go back to the guess and never trust it again.
|
||||||
|
badPorts.add(port);
|
||||||
|
nnPort = guessedPort;
|
||||||
|
log(`port ${port} refused the loopback (${err.message}) — back to port ${guessedPort}`);
|
||||||
|
}
|
||||||
|
nowNextUnavailable(`ProdDash loopback failed: ${err.message}`, NOW_NEXT_RETRY_MS);
|
||||||
|
});
|
||||||
|
req.setTimeout(45000, () => req.destroy(new Error('feed went silent')));
|
||||||
|
nnRequest = req;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — start — */
|
||||||
|
|
||||||
|
if (!pco.configured) {
|
||||||
|
state.lastError = 'No Planning Center credentials — add an Application ID + Secret (or OAuth token) in /admin';
|
||||||
|
log(state.lastError);
|
||||||
|
} else if (!serviceTypeId) {
|
||||||
|
state.lastError = 'No service type selected — choose one under PCO Plan in /admin';
|
||||||
|
log(state.lastError);
|
||||||
|
} else {
|
||||||
|
log(`reading service type ${serviceTypeId} from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`);
|
||||||
|
schedulePoll(0);
|
||||||
|
}
|
||||||
|
if (follow) {
|
||||||
|
state.live.reason = 'Connecting to the ProPresenter module feed';
|
||||||
|
connectNowNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
for (const res of streams) {
|
||||||
|
try {
|
||||||
|
res.write(': ping\n\n');
|
||||||
|
} catch {
|
||||||
|
streams.delete(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
heartbeat.unref?.();
|
||||||
|
|
||||||
|
current = {
|
||||||
|
streams,
|
||||||
|
pco,
|
||||||
|
getState: () => state,
|
||||||
|
learnPort,
|
||||||
|
refresh() {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
return poll();
|
||||||
|
},
|
||||||
|
listPlans,
|
||||||
|
pickPlan,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
clearTimeout(nnRetryTimer);
|
||||||
|
try { nnRequest?.destroy(); } catch { /* gone */ }
|
||||||
|
for (const res of streams) {
|
||||||
|
try { res.end(); } catch { /* already gone */ }
|
||||||
|
}
|
||||||
|
streams.clear();
|
||||||
|
if (current && current.streams === streams) current = null;
|
||||||
|
},
|
||||||
|
health() {
|
||||||
|
if (!pco.configured) return { status: 'error', message: 'No Planning Center credentials configured' };
|
||||||
|
if (!serviceTypeId) return { status: 'error', message: 'No service type selected — pick one in the Service type list below' };
|
||||||
|
if (!state.reachable) {
|
||||||
|
return { status: state.lastFetched ? 'error' : 'connecting', message: state.lastError || 'Reading the plan…' };
|
||||||
|
}
|
||||||
|
const name = state.serviceType.name || serviceTypeId;
|
||||||
|
const plan = state.plan ? ` · ${state.plan.dates}${state.plan.title ? ` — ${state.plan.title}` : ''}` : ' · no plan found';
|
||||||
|
const live = follow ? (state.live.available ? '' : ' · ProPresenter feed unavailable') : '';
|
||||||
|
return { status: 'ok', message: `Following ${name}${plan}${live}` };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
routes({ log }) {
|
||||||
|
const json = (res, status, body) => {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
'GET /state': (req, res) => {
|
||||||
|
current?.learnPort(req.headers.host);
|
||||||
|
json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() });
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /stream': (req, res) => {
|
||||||
|
current?.learnPort(req.headers.host);
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
});
|
||||||
|
res.write('retry: 3000\n\n');
|
||||||
|
const state = current ? current.getState() : clearedState();
|
||||||
|
res.write(`event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`);
|
||||||
|
if (!current) return void res.end();
|
||||||
|
const { streams } = current;
|
||||||
|
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||||
|
streams.add(res);
|
||||||
|
req.on('close', () => streams.delete(res));
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /refresh': (req, res) => {
|
||||||
|
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||||
|
current.refresh().then(
|
||||||
|
() => json(res, 200, { ok: true, state: current?.getState() }),
|
||||||
|
(err) => json(res, 502, { error: err?.message || String(err) })
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for the admin page's "Service type" select: every service
|
||||||
|
* type, grouped by its Planning Center folder path — so the folders
|
||||||
|
* are browsable as <optgroup>s right in the admin form.
|
||||||
|
*/
|
||||||
|
'GET /service-types': async (req, res) => {
|
||||||
|
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||||
|
const { pco } = current;
|
||||||
|
if (!pco.configured) return json(res, 200, { options: [], error: 'No Planning Center credentials yet.' });
|
||||||
|
try {
|
||||||
|
const [folderBody, stBody] = await Promise.all([
|
||||||
|
pco.getAll(`${SERVICES}/folders`),
|
||||||
|
pco.getAll(`${SERVICES}/service_types`),
|
||||||
|
]);
|
||||||
|
const folders = new Map();
|
||||||
|
for (const f of folderBody.data || []) {
|
||||||
|
folders.set(String(f.id), {
|
||||||
|
id: String(f.id),
|
||||||
|
name: String(f.attributes?.name || 'Folder'),
|
||||||
|
parentId: f.relationships?.parent?.data?.id ? String(f.relationships.parent.data.id) : '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const serviceTypes = (stBody.data || []).map((st) => ({
|
||||||
|
id: String(st.id),
|
||||||
|
name: String(st.attributes?.name || 'Service type'),
|
||||||
|
frequency: String(st.attributes?.frequency || ''),
|
||||||
|
parentId: st.relationships?.parent?.data?.id ? String(st.relationships.parent.data.id) : '',
|
||||||
|
}));
|
||||||
|
// Fill in any folder the list did not include (some accounts only
|
||||||
|
// list root folders) by fetching the missing parents individually.
|
||||||
|
const missing = new Set();
|
||||||
|
for (const st of serviceTypes) if (st.parentId && !folders.has(st.parentId)) missing.add(st.parentId);
|
||||||
|
for (const f of folders.values()) if (f.parentId && !folders.has(f.parentId)) missing.add(f.parentId);
|
||||||
|
let guard = 0;
|
||||||
|
while (missing.size && guard < 50) {
|
||||||
|
const id = missing.values().next().value;
|
||||||
|
missing.delete(id);
|
||||||
|
guard += 1;
|
||||||
|
try {
|
||||||
|
const one = (await pco.get(`${SERVICES}/folders/${encodeURIComponent(id)}`))?.data;
|
||||||
|
const parentId = one?.relationships?.parent?.data?.id ? String(one.relationships.parent.data.id) : '';
|
||||||
|
folders.set(id, { id, name: String(one?.attributes?.name || 'Folder'), parentId });
|
||||||
|
if (parentId && !folders.has(parentId)) missing.add(parentId);
|
||||||
|
} catch {
|
||||||
|
folders.set(id, { id, name: `Folder ${id}`, parentId: '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "Campuses › North Campus" — the folder path from the root down.
|
||||||
|
const pathOf = (parentId) => {
|
||||||
|
const names = [];
|
||||||
|
let id = parentId;
|
||||||
|
const seen = new Set();
|
||||||
|
while (id && folders.has(id) && !seen.has(id)) {
|
||||||
|
seen.add(id);
|
||||||
|
names.unshift(folders.get(id).name);
|
||||||
|
id = folders.get(id).parentId;
|
||||||
|
}
|
||||||
|
return names.join(' › ');
|
||||||
|
};
|
||||||
|
const options = serviceTypes
|
||||||
|
.map((st) => ({ value: st.id, label: st.name, group: pathOf(st.parentId) }))
|
||||||
|
.sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
|
||||||
|
json(res, 200, { options });
|
||||||
|
} catch (err) {
|
||||||
|
log(`service type list failed: ${err?.message || err}`);
|
||||||
|
json(res, err?.status === 401 ? 401 : 502, { options: [], error: err?.message || String(err) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for the admin page's "Plan" select, for the service type the
|
||||||
|
* admin currently has picked (?serviceTypeId=…): the upcoming and recent
|
||||||
|
* plans, with the one Automatic would choose marked.
|
||||||
|
*/
|
||||||
|
'GET /plans': async (req, res) => {
|
||||||
|
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||||
|
const { pco } = current;
|
||||||
|
const params = new URLSearchParams(req.search || '');
|
||||||
|
const stId = String(params.get('serviceTypeId') || '').trim();
|
||||||
|
const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' };
|
||||||
|
if (!pco.configured || !stId) return json(res, 200, { options: [auto] });
|
||||||
|
try {
|
||||||
|
const plans = await current.listPlans(stId, { future: 8, past: 6 });
|
||||||
|
const picked = current.pickPlan(plans);
|
||||||
|
const now = Date.now();
|
||||||
|
const options = [auto];
|
||||||
|
// Upcoming first (soonest at the top), then the recent ones.
|
||||||
|
const ordered = [
|
||||||
|
...plans.filter((p) => p.sortDate >= now - 12 * 3600000),
|
||||||
|
...plans.filter((p) => p.sortDate < now - 12 * 3600000).reverse(),
|
||||||
|
];
|
||||||
|
for (const { rec, sortDate } of ordered) {
|
||||||
|
const a = rec.attributes || {};
|
||||||
|
const bits = [String(a.dates || a.short_dates || new Date(sortDate).toLocaleDateString())];
|
||||||
|
if (a.series_title) bits.push(String(a.series_title));
|
||||||
|
if (a.title) bits.push(String(a.title));
|
||||||
|
let label = bits.join(' — ');
|
||||||
|
if (picked && rec.id === picked.id) label += ' (Automatic picks this)';
|
||||||
|
options.push({ value: String(rec.id), label, group: sortDate >= now - 12 * 3600000 ? 'Upcoming' : 'Recent' });
|
||||||
|
}
|
||||||
|
json(res, 200, { options });
|
||||||
|
} catch (err) {
|
||||||
|
json(res, err?.status === 401 ? 401 : 502, { options: [auto], error: err?.message || String(err) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|
||||||
@@ -314,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,7 +456,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
bootTimer = setTimeout(boot, 5000);
|
bootTimer = setTimeout(boot, 5000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scrollToBottom();
|
scrollToLatest();
|
||||||
connect();
|
connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,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,9 +1,12 @@
|
|||||||
{
|
{
|
||||||
"id": "prodcom-transcript",
|
"id": "prodcom-transcript",
|
||||||
"name": "ProdCom",
|
"name": "ProdCom",
|
||||||
"version": "1.0.0",
|
"version": "1.2.1",
|
||||||
"proddash": ">=1.1.0",
|
"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",
|
||||||
@@ -22,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",
|
||||||
|
|||||||
@@ -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. */
|
||||||
@@ -113,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; }
|
||||||
|
|
||||||
@@ -143,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 {
|
||||||
@@ -163,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,9 +1,12 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-now-next",
|
"id": "propresenter-now-next",
|
||||||
"name": "ProPresenter",
|
"name": "ProPresenter",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"proddash": ">=1.1.0",
|
"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",
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-timers",
|
"id": "propresenter-timers",
|
||||||
"name": "Timers",
|
"name": "Timers",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"proddash": ">=1.2.0",
|
"proddash": ">=1.2.0",
|
||||||
"description": "Live ProPresenter timers and LTC timecode",
|
"description": "Live ProPresenter timers and LTC timecode",
|
||||||
|
"permissions": [
|
||||||
|
{ "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" },
|
||||||
|
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
|
||||||
|
],
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client",
|
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
|||||||
@@ -130,6 +130,50 @@ body { overflow: auto; }
|
|||||||
}
|
}
|
||||||
.field-group .field:last-child { margin-bottom: 0; }
|
.field-group .field:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* 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; }
|
||||||
|
.field-group-body.is-columns .field-group-help { grid-column: 1 / -1; }
|
||||||
|
@media (max-width: 560px) { .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } }
|
||||||
|
|
||||||
|
.field-group-help,
|
||||||
|
.field-help {
|
||||||
|
display: block;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.field-group-help { margin: -4px 0 8px; }
|
||||||
|
.field-help { 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
|
/* showWhen: a hidden dependent field must actually collapse — `.field`'s
|
||||||
own `display:flex` (an author rule) otherwise outranks the [hidden] UA
|
own `display:flex` (an author rule) otherwise outranks the [hidden] UA
|
||||||
default and the field stays visible. */
|
default and the field stays visible. */
|
||||||
|
|||||||
+138
-19
@@ -107,6 +107,10 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
|
|||||||
// For boolean/switch fields, the control other fields may depend on
|
// For boolean/switch fields, the control other fields may depend on
|
||||||
// (showWhen) — returned so the caller can wire visibility.
|
// (showWhen) — returned so the caller can wire visibility.
|
||||||
let control = null;
|
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 === 'switch') {
|
if (type === 'switch') {
|
||||||
// Same toggle as the module enable switch, inline with its label.
|
// Same toggle as the module enable switch, inline with its label.
|
||||||
@@ -161,36 +165,76 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
|
|||||||
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');
|
||||||
|
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
|
||||||
const fill = (options) => {
|
const fill = (options) => {
|
||||||
|
// Keep what the admin has picked but not yet saved across a refill
|
||||||
|
// (a dependent select refetching), else the saved value.
|
||||||
|
const saved = String(value ?? '');
|
||||||
|
const wanted = select.options.length ? select.value : saved;
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
// The saved value must stay selectable even when it isn't among the
|
// The saved value must stay selectable even when it isn't among the
|
||||||
// live options (device unplugged, module route down).
|
// live options (device unplugged, module route down).
|
||||||
const saved = String(value ?? '');
|
if (saved && !options.some((opt) => optValue(opt) === saved)) {
|
||||||
if (saved && !options.some((opt) => String(typeof opt === 'object' ? opt.value : opt) === saved)) {
|
|
||||||
options = [{ value: saved, label: `${saved} (saved)` }, ...options];
|
options = [{ value: saved, label: `${saved} (saved)` }, ...options];
|
||||||
}
|
}
|
||||||
if (!saved) options = [{ value: '', label: '— choose —' }, ...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) {
|
for (const opt of options) {
|
||||||
const o = document.createElement('option');
|
const o = document.createElement('option');
|
||||||
o.value = String(typeof opt === 'object' ? opt.value : opt);
|
o.value = optValue(opt);
|
||||||
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
||||||
select.appendChild(o);
|
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 = saved;
|
select.value = options.some((opt) => optValue(opt) === wanted) ? wanted : saved;
|
||||||
};
|
};
|
||||||
fill(Array.isArray(spec.options) ? spec.options : []);
|
fill(Array.isArray(spec.options) ? spec.options : []);
|
||||||
if (spec.optionsRoute && moduleId) {
|
if (spec.optionsRoute && moduleId) {
|
||||||
// Options discovered at runtime (audio devices, ports, sources…):
|
// Options discovered at runtime (audio devices, ports, sources…):
|
||||||
// the module serves them from one of its own routes.
|
// the module serves them from one of its own routes. With
|
||||||
fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}`)
|
// optionsDependsOn the caller triggers the fetch (and re-fetches when
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
// that other field changes), passing its value as a query parameter.
|
||||||
.then((body) => {
|
refetch = (query) => {
|
||||||
if (Array.isArray(body?.options)) fill(body.options);
|
const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : '';
|
||||||
})
|
return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`)
|
||||||
.catch(() => { /* keep the static/saved options */ });
|
.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();
|
||||||
}
|
}
|
||||||
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;
|
||||||
@@ -205,7 +249,32 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
|
|||||||
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, control };
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildModuleCard(mod) {
|
function buildModuleCard(mod) {
|
||||||
@@ -319,32 +388,62 @@ function buildModuleCard(mod) {
|
|||||||
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 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 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
|
// Fields render in schema order; a `group` starts a labeled subsection
|
||||||
// and consecutive same-group fields share it.
|
// and consecutive same-group fields share it. The manifest's optional
|
||||||
|
// `configGroups[name]` adds { collapsed, columns, help } for that group.
|
||||||
|
const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {};
|
||||||
let groupName = null;
|
let groupName = null;
|
||||||
let container = form;
|
let container = form;
|
||||||
|
let currentGroup = null;
|
||||||
for (const [key, spec] of schemaKeys) {
|
for (const [key, spec] of schemaKeys) {
|
||||||
const g = spec?.group || '';
|
const g = spec?.group || '';
|
||||||
if (g !== groupName) {
|
if (g !== groupName) {
|
||||||
groupName = g;
|
groupName = g;
|
||||||
|
currentGroup = null;
|
||||||
if (g) {
|
if (g) {
|
||||||
const group = document.createElement('div');
|
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';
|
group.className = 'field-group';
|
||||||
const title = document.createElement('div');
|
const title = document.createElement(folds ? 'summary' : 'div');
|
||||||
title.className = 'field-group-title';
|
title.className = 'field-group-title';
|
||||||
title.textContent = g;
|
title.textContent = g;
|
||||||
group.appendChild(title);
|
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));
|
||||||
|
}
|
||||||
|
if (meta.help) {
|
||||||
|
const help = document.createElement('div');
|
||||||
|
help.className = 'field-group-help';
|
||||||
|
help.textContent = meta.help;
|
||||||
|
body.appendChild(help);
|
||||||
|
}
|
||||||
|
group.appendChild(body);
|
||||||
form.appendChild(group);
|
form.appendChild(group);
|
||||||
container = group;
|
container = body;
|
||||||
|
currentGroup = { el: group, meta, fields: [] };
|
||||||
|
if (folds) collapsibles.push(currentGroup);
|
||||||
} else {
|
} else {
|
||||||
container = form;
|
container = form;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const { field, read, control } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key], mod.id);
|
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);
|
||||||
if (control) controls.set(key, control);
|
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 (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
|
||||||
|
if (currentGroup) currentGroup.fields.push({ key, spec });
|
||||||
container.appendChild(field);
|
container.appendChild(field);
|
||||||
}
|
}
|
||||||
// A field with showWhen:"K" is visible only while K's toggle is on.
|
// A field with showWhen:"K" is visible only while K's toggle is on.
|
||||||
@@ -355,6 +454,22 @@ function buildModuleCard(mod) {
|
|||||||
ctl.addEventListener('change', sync);
|
ctl.addEventListener('change', sync);
|
||||||
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');
|
||||||
@@ -380,6 +495,10 @@ function buildModuleCard(mod) {
|
|||||||
status.textContent = 'Saved — module re-initialised. Open tiles reconnect on their own.';
|
status.textContent = 'Saved — 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';
|
||||||
|
|||||||
+68
-1
@@ -919,6 +919,14 @@ function unmountModule(tile) {
|
|||||||
|
|
||||||
/* ── per-tile settings popover ──────────────────────────────────────── */
|
/* ── per-tile settings popover ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** `<input type="color">` only accepts #rrggbb — anything else falls back. */
|
||||||
|
function colorHex(value, fallback) {
|
||||||
|
const s = String(value ?? '').trim();
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase();
|
||||||
|
if (/^#[0-9a-fA-F]{3}$/.test(s)) return ('#' + s.slice(1).split('').map((c) => c + c).join('')).toLowerCase();
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function closeSettingsPopovers() {
|
function closeSettingsPopovers() {
|
||||||
document.querySelectorAll('.tile-settings').forEach((p) => p.remove());
|
document.querySelectorAll('.tile-settings').forEach((p) => p.remove());
|
||||||
}
|
}
|
||||||
@@ -934,11 +942,54 @@ function toggleSettingsPopover(tile, el) {
|
|||||||
pop.className = 'tile-settings';
|
pop.className = 'tile-settings';
|
||||||
const inputs = new Map();
|
const inputs = new Map();
|
||||||
|
|
||||||
|
// Fields render in schema order; a field's `group` starts a labeled
|
||||||
|
// subsection that consecutive same-group fields share, and the manifest's
|
||||||
|
// optional `instanceGroups[name]` adds { collapsed, columns, help } —
|
||||||
|
// the same conventions as the admin page's config form.
|
||||||
|
const groupMeta = man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {};
|
||||||
|
let groupName = null;
|
||||||
|
let container = pop;
|
||||||
|
|
||||||
for (const [key, spec] of Object.entries(man.instanceSchema)) {
|
for (const [key, spec] of Object.entries(man.instanceSchema)) {
|
||||||
const type = spec?.type || 'string';
|
const type = spec?.type || 'string';
|
||||||
const label = spec?.label || key;
|
const label = spec?.label || key;
|
||||||
const current = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
|
const current = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
|
||||||
|
|
||||||
|
const g = spec?.group || '';
|
||||||
|
if (g !== groupName) {
|
||||||
|
groupName = g;
|
||||||
|
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';
|
||||||
|
if (folds) group.open = !meta.collapsed;
|
||||||
|
const title = document.createElement(folds ? 'summary' : 'div');
|
||||||
|
title.className = 'field-group-title';
|
||||||
|
title.textContent = g;
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
if (meta.help) {
|
||||||
|
const help = document.createElement('div');
|
||||||
|
help.className = 'field-group-help';
|
||||||
|
help.textContent = meta.help;
|
||||||
|
body.appendChild(help);
|
||||||
|
}
|
||||||
|
group.appendChild(body);
|
||||||
|
pop.appendChild(group);
|
||||||
|
pop.classList.add('has-groups');
|
||||||
|
container = body;
|
||||||
|
} else {
|
||||||
|
container = pop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const field = document.createElement('label');
|
const field = document.createElement('label');
|
||||||
field.className = 'field' + (type === 'boolean' ? ' check' : '');
|
field.className = 'field' + (type === 'boolean' ? ' check' : '');
|
||||||
|
|
||||||
@@ -961,6 +1012,16 @@ function toggleSettingsPopover(tile, el) {
|
|||||||
select.value = String(current ?? '');
|
select.value = String(current ?? '');
|
||||||
field.append(span, select);
|
field.append(span, select);
|
||||||
inputs.set(key, () => select.value);
|
inputs.set(key, () => select.value);
|
||||||
|
} 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';
|
||||||
|
input.value = colorHex(current, colorHex(spec?.default, '#2ee59a'));
|
||||||
|
field.append(span, input);
|
||||||
|
inputs.set(key, () => input.value);
|
||||||
} else {
|
} else {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
@@ -970,7 +1031,13 @@ function toggleSettingsPopover(tile, el) {
|
|||||||
field.append(span, input);
|
field.append(span, input);
|
||||||
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
|
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
|
||||||
}
|
}
|
||||||
pop.appendChild(field);
|
if (spec?.help) {
|
||||||
|
const help = document.createElement('small');
|
||||||
|
help.className = 'field-help';
|
||||||
|
help.textContent = spec.help;
|
||||||
|
field.appendChild(help);
|
||||||
|
}
|
||||||
|
container.appendChild(field);
|
||||||
}
|
}
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
|
|||||||
@@ -547,6 +547,70 @@ body.fullscreen #grid { padding-bottom: 0; }
|
|||||||
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; }
|
||||||
|
.tile-settings .field-group-body.is-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
|
||||||
|
gap: 3px 14px;
|
||||||
|
}
|
||||||
|
.tile-settings .field-group-body.is-columns .field { margin-bottom: 3px; }
|
||||||
|
.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;
|
||||||
@@ -580,6 +644,27 @@ body.fullscreen #grid { padding-bottom: 0; }
|
|||||||
}
|
}
|
||||||
.field.check input { width: 15px; height: 15px; accent-color: var(--accent); }
|
.field.check input { width: 15px; height: 15px; accent-color: var(--accent); }
|
||||||
|
|
||||||
|
/* 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.) ───────────────────────── */
|
||||||
|
|
||||||
#modal-backdrop {
|
#modal-backdrop {
|
||||||
|
|||||||
@@ -508,6 +508,7 @@ 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),
|
||||||
config: clientConfig(id),
|
config: clientConfig(id),
|
||||||
tiles: await moduleTiles(id),
|
tiles: await moduleTiles(id),
|
||||||
@@ -973,6 +974,7 @@ function adminModuleView(id) {
|
|||||||
incompatible: man.incompatible || '',
|
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 || '',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user