Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b81efcb51 | ||
|
|
409f2c258e | ||
|
|
7f6e5bd151 | ||
|
|
2307770ce9 | ||
|
|
fe3811b03e | ||
|
|
41811ead1c | ||
|
|
0929af46ca | ||
|
|
2817a444c3 | ||
|
|
c11389473f | ||
|
|
c6a44e6ddc | ||
|
|
2f14b30d68 | ||
|
|
b5d39c60dc | ||
|
|
407eaecc23 | ||
|
|
ac1f17b9ed | ||
|
|
c37575f662 | ||
|
|
07e4c78417 | ||
|
|
9ca301b4be | ||
|
|
ece2c0a2c3 | ||
|
|
32180bf299 | ||
|
|
06066529bc | ||
|
|
4e5b60e82b | ||
|
|
601dc9e8b0 | ||
|
|
92b98b6579 | ||
|
|
58f3e5010a | ||
|
|
7cc19ef696 | ||
|
|
86a3270f1c | ||
|
|
e9d6edc2c8 | ||
|
|
b3b7b29007 | ||
|
|
b5c539ba05 | ||
|
|
9313c9e42a |
+2
-6
@@ -8,12 +8,8 @@ dist/
|
|||||||
config/modules.json
|
config/modules.json
|
||||||
config/layouts/
|
config/layouts/
|
||||||
|
|
||||||
# compiled per-machine, source sits next to it
|
|
||||||
modules/propresenter-timers/ltc-capture
|
|
||||||
|
|
||||||
# the launcher's build output, and its cache of the Node runtime it embeds
|
# the launcher's build output, and its cache of the Node runtime it embeds
|
||||||
launcher/macos/build/
|
launcher/macos/build/
|
||||||
launcher/macos/.cache/
|
launcher/macos/.cache/
|
||||||
|
launcher/windows/build/
|
||||||
# Compiled per-machine, source sits next to it (see spl-capture.swift).
|
launcher/windows/.cache/
|
||||||
modules/spl-meter/spl-capture
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
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/`, and a native macOS menu-bar launcher in
|
`/admin`, modules in `modules/` (the repo's copies — a packaged install ships
|
||||||
|
none and installs them from the admin page's catalog), and a native macOS menu-bar launcher in
|
||||||
`launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
|
`launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
|
||||||
the server and its own Node runtime, supervises them, and owns the macOS
|
the server and its own Node runtime, supervises them, and owns the macOS
|
||||||
permissions modules declare; see its README). Runtime state (module
|
permissions modules declare; see its README). Runtime state (module
|
||||||
@@ -28,7 +29,12 @@ title-bar controls). The rules that matter most:
|
|||||||
ship inside the module folder** (zero deps is the norm). **Everything the
|
ship inside the module folder** (zero deps is the norm). **Everything the
|
||||||
module does runs inside its server entry** — no helper scripts, companion
|
module does runs inside its server entry** — no helper scripts, companion
|
||||||
programs or daemons the operator starts by hand; a listener or decoder is
|
programs or daemons the operator starts by hand; a listener or decoder is
|
||||||
started in `init()` and stopped in `stop()`. Declare `"proddash": ">=x.y.z"`
|
started in `init()` and stopped in `stop()`. **A native helper ships
|
||||||
|
prebuilt inside the module folder for Apple silicon macOS and 64-bit
|
||||||
|
Windows** (`bin/<name>-macos-arm64`, `bin/<name>-win-x64.exe`, source and
|
||||||
|
build script under `native/`); nothing is ever compiled on the machine
|
||||||
|
running ProdDash, and a missing binary means the feature reports itself
|
||||||
|
off, not a broken module. Declare `"proddash": ">=x.y.z"`
|
||||||
and bump the module's `version` on every change.
|
and bump the module's `version` on every change.
|
||||||
- Browsers never call upstream services directly; the module's server routes
|
- Browsers never call upstream services directly; the module's server routes
|
||||||
proxy everything (no CORS, secrets stay server-side).
|
proxy everything (no CORS, secrets stay server-side).
|
||||||
|
|||||||
@@ -30,11 +30,23 @@ and a clear degraded state, then recover on their own.
|
|||||||
|
|
||||||
## Run it
|
## Run it
|
||||||
|
|
||||||
|
Ready-made builds are on the
|
||||||
|
[Releases page](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash/releases);
|
||||||
|
the files themselves live in
|
||||||
|
[ProdDash-releases](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash-releases).
|
||||||
|
There is a disk image for Apple silicon Macs, one for Intel Macs, and a
|
||||||
|
bundle for 64-bit Windows. Every build updates itself afterwards from the
|
||||||
|
admin page, so the download only has to be recent, not current.
|
||||||
|
|
||||||
### macOS — the app
|
### macOS — the app
|
||||||
|
|
||||||
Open the disk image and drag **ProdDash** to Applications. That's the whole
|
Download the disk image for your Mac — `ProdDash-<version>-arm64.dmg` for
|
||||||
|
Apple silicon, `ProdDash-<version>-x86_64.dmg` for Intel — open it and drag
|
||||||
|
**ProdDash** to Applications. That's the whole
|
||||||
installation: the app carries the dashboard and its own Node runtime, so
|
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
|
nothing else has to be installed. It carries no modules — open **Admin →
|
||||||
|
Available modules**, tick the ones this machine needs and press **Install
|
||||||
|
selected**; each updates on its own from then on. It lives in the
|
||||||
menu bar, starts and stops the server, can open at login, and holds 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
|
permissions the modules need — the microphone the LTC listener reads, and
|
||||||
local network access for ProdCom and ProPresenter. macOS grants those to the
|
local network access for ProdCom and ProPresenter. macOS grants those to the
|
||||||
@@ -43,12 +55,26 @@ a launchd job) simply doesn't have them, and an LTC input goes silently to
|
|||||||
zeroes.
|
zeroes.
|
||||||
|
|
||||||
Build the disk image from a checkout with
|
Build the disk image from a checkout with
|
||||||
[launcher/macos](launcher/macos)/`build.sh`:
|
[launcher/macos](launcher/macos)/`build.sh` (`ARCH=x86_64` in front of it
|
||||||
|
for Intel):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd launcher/macos && ./build.sh --dmg
|
cd launcher/macos && ./build.sh --dmg
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Windows — the bundle
|
||||||
|
|
||||||
|
Download `ProdDash-<version>-win-x64.zip`, unzip it somewhere you can write
|
||||||
|
to — `C:\ProdDash`, say, not Program Files, because updates write into the
|
||||||
|
folder — and double-click **ProdDash.bat**. The window it opens is the
|
||||||
|
dashboard running: it stays open, and it starts the server again when the
|
||||||
|
admin page applies an update. Allow `node.exe` on private networks when the
|
||||||
|
firewall asks, so other screens can reach it. Settings and layouts live in
|
||||||
|
`%APPDATA%\ProdDash`, so a new bundle never loses them. To start with
|
||||||
|
Windows, put a shortcut to `ProdDash.bat` in `shell:startup`. There is no
|
||||||
|
menu-bar-style launcher for Windows yet; the bundle is built with
|
||||||
|
[launcher/windows](launcher/windows)/`build.sh`.
|
||||||
|
|
||||||
### Anywhere else, or from a checkout
|
### Anywhere else, or from a checkout
|
||||||
|
|
||||||
Requires [Node.js](https://nodejs.org) 18 or newer.
|
Requires [Node.js](https://nodejs.org) 18 or newer.
|
||||||
@@ -179,13 +205,15 @@ below. Nothing is installed without someone clicking.
|
|||||||
- **Available modules** lists modules in the repo that aren't installed
|
- **Available modules** lists modules in the repo that aren't installed
|
||||||
here. **Install** downloads the module into the data directory's `modules/`
|
here. **Install** downloads the module into the data directory's `modules/`
|
||||||
and starts it right away — nothing is written into the app folder, and no
|
and starts it right away — nothing is written into the app folder, and no
|
||||||
restart is needed. Modules that ship with your copy of ProdDash are
|
restart is needed. Tick several and press **Install selected** to fetch
|
||||||
"bundled"; ones you installed are "installed"; if both exist, the newer
|
them in one go. A packaged ProdDash (the app, the Windows bundle) ships
|
||||||
version runs.
|
with no modules at all: this list is how every one of them arrives, and a
|
||||||
|
ProdDash update never brings one along. A git checkout also runs the
|
||||||
|
modules in its own tree ("bundled"); if both exist, the newer version runs.
|
||||||
- **Uninstall** on a module card removes an installed module's files. For a
|
- **Uninstall** on a module card removes an installed module's files. For a
|
||||||
bundled module (its folder belongs to the app) it hides the module from
|
checkout's bundled module (its folder belongs to the repo) it hides the
|
||||||
every dashboard instead; it reappears under Available modules and can be
|
module from every dashboard instead; it reappears under Available modules
|
||||||
brought back without downloading. Settings are kept either way.
|
and can be brought back without downloading. Settings are kept either way.
|
||||||
- **Module updates**: when the repo has a newer version of an installed
|
- **Module updates**: when the repo has a newer version of an installed
|
||||||
module, its card shows **Update to vX**.
|
module, its card shows **Update to vX**.
|
||||||
- **Version requirements**: every module declares the ProdDash versions it
|
- **Version requirements**: every module declares the ProdDash versions it
|
||||||
@@ -303,9 +331,11 @@ below. Nothing is installed without someone clicking.
|
|||||||
status, NOW and NEXT labels, slide count, progress bar, the next list and
|
status, NOW and NEXT labels, slide count, progress bar, the next list and
|
||||||
its item tags, plus tidy titles.
|
its item tags, plus tidy titles.
|
||||||
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
||||||
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
|
elapsed), the **video countdown** (time left on what ProPresenter is
|
||||||
discovered timer (and the LTC card) as its own tile, or add "All timers"
|
playing, with a per-tile "Looping videos" switch so looping backgrounds
|
||||||
and pick a selection via the tile's "Timers ▾" menu; overruns are
|
stay hidden) and LTC timecode as auto-sized cards. The + picker lists
|
||||||
|
every discovered timer (and the LTC card) as its own tile, or add "All
|
||||||
|
timers" and pick a selection via the tile's "Timers ▾" menu; overruns are
|
||||||
unmistakably red. Read-only — no start/stop controls. Admin config is in
|
unmistakably red. Read-only — no start/stop controls. Admin config is in
|
||||||
three groups: **ProPresenter** (endpoint host : port, API password),
|
three groups: **ProPresenter** (endpoint host : port, API password),
|
||||||
**LTC Timecode** (the listener switch, and — when it's on — the audio
|
**LTC Timecode** (the listener switch, and — when it's on — the audio
|
||||||
@@ -323,8 +353,9 @@ 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** — Planning Center Services, as two tiles in the + picker:
|
- **PCO Plan** — Planning Center Services, as three tiles in the + picker:
|
||||||
**Plan** (the order of service) and **Teams** (who is scheduled).
|
**Plan** (the order of service), **Teams** (who is scheduled) and **Song
|
||||||
|
Key** (the key of the item on screen).
|
||||||
|
|
||||||
**Plan** shows every
|
**Plan** shows every
|
||||||
plan item with its scheduled start time, length, type, song key,
|
plan item with its scheduled start time, length, type, song key,
|
||||||
@@ -355,6 +386,16 @@ below. Nothing is installed without someone clicking.
|
|||||||
is switched off, or it has no host — nothing to turn off and nothing to
|
is switched off, or it has no host — nothing to turn off and nothing to
|
||||||
explain.
|
explain.
|
||||||
|
|
||||||
|
**Song Key** is one value, as large as the tile allows: the key of the item
|
||||||
|
ProPresenter has live, with the song's name and the next key under it — both
|
||||||
|
switches, as is the colour. When the feed that picks the item is gone the key
|
||||||
|
stays up, faded, and the tile's dot says so rather than pretending to be
|
||||||
|
current.
|
||||||
|
|
||||||
|
Keys are written as notes wherever the module shows one: Planning Center's
|
||||||
|
`Bb` and `F#m` read as B♭ and F♯m, on the Plan tile's item badge and in the
|
||||||
|
Song Key tile alike.
|
||||||
|
|
||||||
**Teams** shows the same plan's team and position assignments — each team
|
**Teams** shows the same plan's team and position assignments — each team
|
||||||
as a band of its people, with their photo (fetched through ProdDash, never
|
as a band of its people, with their photo (fetched through ProdDash, never
|
||||||
by the browser; initials when Planning Center has none) and a dot for
|
by the browser; initials when Planning Center has none) and a dot for
|
||||||
|
|||||||
+80
-9
@@ -28,7 +28,10 @@ people running the booth depend on them.
|
|||||||
ProdDash. If your feature needs a listener, a decoder or a worker, your
|
ProdDash. If your feature needs a listener, a decoder or a worker, your
|
||||||
server entry owns it: start it in `init()`, stop it in `stop()`, report
|
server entry owns it: start it in `init()`, stop it in `stop()`, report
|
||||||
it in `health()`. A child process the module itself spawns and supervises
|
it in `health()`. A child process the module itself spawns and supervises
|
||||||
is fine; a program someone launches by hand is not.
|
is fine; a program someone launches by hand is not. **A native helper —
|
||||||
|
anything that is not JavaScript — ships prebuilt inside the module folder,
|
||||||
|
for Apple silicon macOS and 64-bit Windows; nothing is ever compiled on
|
||||||
|
the machine running ProdDash** (see "Native helpers" below).
|
||||||
4. **Declare the ProdDash version you need** (`"proddash": ">=1.1.0"` in the
|
4. **Declare the ProdDash version you need** (`"proddash": ">=1.1.0"` in the
|
||||||
manifest) and **bump your own `version`** whenever you change the module.
|
manifest) and **bump your own `version`** whenever you change the module.
|
||||||
The admin page refuses to install a module whose requirement the running
|
The admin page refuses to install a module whose requirement the running
|
||||||
@@ -83,19 +86,86 @@ modules/<id>/
|
|||||||
├── module.json manifest (required)
|
├── module.json manifest (required)
|
||||||
├── client.js ES module, client entry (required)
|
├── client.js ES module, client entry (required)
|
||||||
├── server.js server entry (optional — only if you need server code)
|
├── server.js server entry (optional — only if you need server code)
|
||||||
|
├── bin/ native helpers, prebuilt per platform (only if you need one)
|
||||||
|
│ ├── <name>-macos-arm64
|
||||||
|
│ └── <name>-win-x64.exe
|
||||||
|
├── native/ their source and the script that builds every bin/ file
|
||||||
└── ... assets (css, images…) served statically at /modules/<id>/
|
└── ... assets (css, images…) served statically at /modules/<id>/
|
||||||
```
|
```
|
||||||
|
|
||||||
**Installation is exactly this:** in the admin page, open **Available
|
**Installation is exactly this:** in the admin page, open **Available
|
||||||
modules** and press **Install** — the module is downloaded from the ProdDash
|
modules** and press **Install** — the module is downloaded from the ProdDash
|
||||||
repo into the server's data directory (`<data dir>/modules/<id>/`) and starts
|
repo into the server's data directory (`<data dir>/modules/<id>/`) and starts
|
||||||
immediately. Developing locally? Drop the folder into `modules/` (bundled
|
immediately. A packaged ProdDash — the macOS app, the Windows bundle — ships
|
||||||
with the checkout) or into the data directory's `modules/` and restart. No
|
with **no modules at all**: every one of them arrives this way, and the
|
||||||
shell code changes, ever. Modules bundled with a checkout and modules
|
shell's own update never brings one along, so what is in git is exactly what
|
||||||
installed from the admin page live side by side; when both have the same id
|
runs on a booth. Developing locally? A git checkout runs whatever is in its
|
||||||
the newer version runs. **Uninstall** in the admin page removes an installed
|
own `modules/` folder (the repo's copies, "bundled"), or drop a folder into
|
||||||
module's files (a bundled one is hidden instead, since its folder belongs to
|
the data directory's `modules/` and restart. No shell code changes, ever. In
|
||||||
the app); its settings are kept for a later reinstall.
|
a checkout the two live side by side; when both have the same id the newer
|
||||||
|
version runs. **Uninstall** in the admin page removes an installed module's
|
||||||
|
files (in a checkout, the tree's copy is hidden instead, since that folder
|
||||||
|
belongs to the repo); its settings are kept for a later reinstall.
|
||||||
|
|
||||||
|
### Native helpers
|
||||||
|
|
||||||
|
Some features need a program JavaScript cannot be: reading an audio input,
|
||||||
|
decoding timecode, measuring sound. Rule 3 says the module owns that program;
|
||||||
|
this section says how it ships: **already built, for every platform it runs
|
||||||
|
on, inside the module folder.** The machine running ProdDash is a packaged
|
||||||
|
app or the Windows bundle plus modules from the admin page's catalog. It has
|
||||||
|
no compiler and never will — the catalog delivers exactly the folder that is
|
||||||
|
in the repo, so the binaries live in git next to the code.
|
||||||
|
|
||||||
|
- **Platforms.** Apple silicon macOS and 64-bit Windows are required. Intel
|
||||||
|
macOS is welcome but optional. One file per platform in `bin/`, named for
|
||||||
|
what it is:
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
| --- | --- |
|
||||||
|
| macOS, Apple silicon | `bin/<name>-macos-arm64` |
|
||||||
|
| Windows, 64-bit | `bin/<name>-win-x64.exe` |
|
||||||
|
| macOS, Intel (optional) | `bin/<name>-macos-x86_64` |
|
||||||
|
|
||||||
|
- **Pick it at runtime, and cope without it.** Choose by `process.platform`
|
||||||
|
and `process.arch`; when there is no file for this machine, the feature
|
||||||
|
says so — in `health()` and in the tile's `setStatus` — and everything else
|
||||||
|
in the module keeps working. Never try to build it, never look for a copy
|
||||||
|
elsewhere on the machine, never tell the operator to install anything.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const HELPERS = {
|
||||||
|
darwin: { arm64: 'ltc-capture-macos-arm64', x64: 'ltc-capture-macos-x86_64' },
|
||||||
|
win32: { x64: 'ltc-capture-win-x64.exe' },
|
||||||
|
};
|
||||||
|
const name = HELPERS[process.platform]?.[process.arch];
|
||||||
|
const helper = name ? path.join(__dirname, 'bin', name) : '';
|
||||||
|
if (!helper || !fs.existsSync(helper)) {
|
||||||
|
// Timecode is off on this platform. Say it once, keep the clock running.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **One contract, any language.** Every platform's binary speaks the same
|
||||||
|
command line and output format, so `server.js` has one code path. What is
|
||||||
|
behind it is your choice per platform — Swift against CoreAudio on macOS,
|
||||||
|
C#, C or Rust on Windows — the module does not care.
|
||||||
|
- **Source and build beside it.** Keep the source under `native/` with one
|
||||||
|
script that produces every `bin/` file from a checkout, so any binary can be
|
||||||
|
rebuilt from what is committed. A helper that cannot be rebuilt does not
|
||||||
|
ship.
|
||||||
|
- **Bump the module version whenever a binary changes**, like any other
|
||||||
|
file — that is how installs learn about it.
|
||||||
|
- **Keep them lean.** Optimised builds, no debug symbols, a few megabytes at
|
||||||
|
most: the catalog fetches the whole repo as one archive, capped at 80 MB.
|
||||||
|
- **Signing.** Apple silicon refuses unsigned code, so sign the macOS binary
|
||||||
|
— Developer ID when you have it, ad-hoc at least. Windows needs nothing:
|
||||||
|
files the app writes carry no download mark, so SmartScreen never sees
|
||||||
|
them. Permissions come from the launcher (see "Permissions macOS has to
|
||||||
|
grant"), not from the binary.
|
||||||
|
|
||||||
|
A helper that ends up existing for one platform only is an exception made
|
||||||
|
on purpose, not a default: say so in the module's README and in the health
|
||||||
|
line, so a Windows booth knows why the feature is off.
|
||||||
|
|
||||||
## module.json
|
## module.json
|
||||||
|
|
||||||
@@ -391,7 +461,8 @@ const menu = moduleApi.header.addMenu({
|
|||||||
build(menuEl) {
|
build(menuEl) {
|
||||||
// called on every open with an emptied menu element — fill it with
|
// called on every open with an emptied menu element — fill it with
|
||||||
// buttons (give them your scoped classes; the container is styled and
|
// buttons (give them your scoped classes; the container is styled and
|
||||||
// fixed-positioned by the shell, so the tile's overflow can't clip it)
|
// positioned by the shell, hosted on the body under an element carrying
|
||||||
|
// your data-module, so your scoped CSS applies and nothing clips it)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ the cycle rather than spinning forever.
|
|||||||
```bash
|
```bash
|
||||||
./build.sh # build/ProdDash.app — the whole application
|
./build.sh # build/ProdDash.app — the whole application
|
||||||
./build.sh --dmg # …and build/ProdDash-<version>-arm64.dmg
|
./build.sh --dmg # …and build/ProdDash-<version>-arm64.dmg
|
||||||
|
ARCH=x86_64 ./build.sh --dmg # the same for Intel Macs (…-x86_64.dmg)
|
||||||
./build.sh --install # …and copy it to /Applications
|
./build.sh --install # …and copy it to /Applications
|
||||||
./build.sh --run # …and open it
|
./build.sh --run # …and open it
|
||||||
./build.sh --notarize # with --dmg: notarise and staple (Developer ID)
|
./build.sh --notarize # with --dmg: notarise and staple (Developer ID)
|
||||||
@@ -68,9 +69,10 @@ Builds for **Apple Silicon**. To change that, or to move to a newer Node, edit
|
|||||||
against nodejs.org's published checksums either way.
|
against nodejs.org's published checksums either way.
|
||||||
|
|
||||||
The app icon is built from `public/icons/icon-512.png`, so ProdDash's own icon
|
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
|
is the app's icon. The app carries no modules: they are installed from the
|
||||||
(`<name>.swift` beside a module's files becomes `<name>`), so the Mac running
|
admin page's catalog into the data directory, and a module's native helper
|
||||||
ProdDash never needs a compiler.
|
ships prebuilt inside it (see the module guide), so the Mac running ProdDash
|
||||||
|
never needs a compiler.
|
||||||
|
|
||||||
## Signing
|
## Signing
|
||||||
|
|
||||||
|
|||||||
+15
-25
@@ -1,6 +1,8 @@
|
|||||||
#!/bin/zsh
|
#!/bin/zsh
|
||||||
# Build ProdDash.app — the whole application: launcher, server and its own
|
# Build ProdDash.app — the whole application: launcher, server and its own
|
||||||
# Node runtime, with nothing to install first.
|
# Node runtime, with nothing to install first. It carries no modules: those
|
||||||
|
# come from the admin page's catalog into the data directory, and a module's
|
||||||
|
# native helper ships prebuilt inside it (docs/MODULE-GUIDE.md).
|
||||||
#
|
#
|
||||||
# ./build.sh build/ProdDash.app, self-contained
|
# ./build.sh build/ProdDash.app, self-contained
|
||||||
# ./build.sh --dmg …and build/ProdDash-<version>-arm64.dmg
|
# ./build.sh --dmg …and build/ProdDash-<version>-arm64.dmg
|
||||||
@@ -11,6 +13,8 @@
|
|||||||
# ./build.sh --dev launcher only — no Node, no server payload, so
|
# ./build.sh --dev launcher only — no Node, no server payload, so
|
||||||
# it runs against the checkout. Seconds, not
|
# it runs against the checkout. Seconds, not
|
||||||
# minutes, while working on the launcher itself.
|
# minutes, while working on the launcher itself.
|
||||||
|
# ARCH=x86_64 ./build.sh the same for Intel Macs (default: arm64, Apple
|
||||||
|
# silicon); the Node it embeds follows.
|
||||||
#
|
#
|
||||||
# Signing: a "Developer ID Application" certificate is used when one is
|
# Signing: a "Developer ID Application" certificate is used when one is
|
||||||
# installed (override with CODESIGN_IDENTITY). Without one the app is signed
|
# installed (override with CODESIGN_IDENTITY). Without one the app is signed
|
||||||
@@ -27,8 +31,13 @@ LAUNCHER_VERSION="1.0.0"
|
|||||||
# The Node the app ships. Bump it here; the download is checksum-verified
|
# The Node the app ships. Bump it here; the download is checksum-verified
|
||||||
# against nodejs.org's own SHASUMS256.txt and cached in .cache/.
|
# against nodejs.org's own SHASUMS256.txt and cached in .cache/.
|
||||||
NODE_VERSION="v24.21.0"
|
NODE_VERSION="v24.21.0"
|
||||||
ARCH="arm64"
|
ARCH="${ARCH:-arm64}" # arm64 (Apple silicon) or x86_64 (Intel)
|
||||||
DEPLOYMENT_TARGET="13.0"
|
DEPLOYMENT_TARGET="13.0"
|
||||||
|
case "$ARCH" in
|
||||||
|
arm64) NODE_ARCH="arm64" ;;
|
||||||
|
x86_64) NODE_ARCH="x64" ;; # nodejs.org's name for it
|
||||||
|
*) echo "build.sh: ARCH must be arm64 or x86_64, not $ARCH" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
REPO_ROOT="../.."
|
REPO_ROOT="../.."
|
||||||
OUT_DIR="build"
|
OUT_DIR="build"
|
||||||
@@ -106,7 +115,7 @@ fi
|
|||||||
|
|
||||||
# ── the server, and the Node that runs it ────────────────────────────────
|
# ── the server, and the Node that runs it ────────────────────────────────
|
||||||
if [[ $DEV -eq 0 ]]; then
|
if [[ $DEV -eq 0 ]]; then
|
||||||
TARBALL="node-$NODE_VERSION-darwin-$ARCH.tar.gz"
|
TARBALL="node-$NODE_VERSION-darwin-$NODE_ARCH.tar.gz"
|
||||||
mkdir -p "$CACHE_DIR"
|
mkdir -p "$CACHE_DIR"
|
||||||
if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then
|
if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then
|
||||||
echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)"
|
echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)"
|
||||||
@@ -125,25 +134,16 @@ if [[ $DEV -eq 0 ]]; then
|
|||||||
echo " embedding Node $NODE_VERSION"
|
echo " embedding Node $NODE_VERSION"
|
||||||
mkdir -p "$CONTENTS/Resources/node/bin"
|
mkdir -p "$CONTENTS/Resources/node/bin"
|
||||||
tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \
|
tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \
|
||||||
"node-$NODE_VERSION-darwin-$ARCH/bin/node"
|
"node-$NODE_VERSION-darwin-$NODE_ARCH/bin/node"
|
||||||
|
|
||||||
echo " embedding ProdDash $VERSION"
|
echo " embedding ProdDash $VERSION (no modules — the catalog installs them)"
|
||||||
mkdir -p "$PAYLOAD/config"
|
mkdir -p "$PAYLOAD/config"
|
||||||
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$PAYLOAD/"
|
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$PAYLOAD/"
|
||||||
cp -R "$REPO_ROOT/public" "$REPO_ROOT/modules" "$PAYLOAD/"
|
cp -R "$REPO_ROOT/public" "$PAYLOAD/"
|
||||||
# Only the checked-in defaults: config/ also holds this machine's own
|
# Only the checked-in defaults: config/ also holds this machine's own
|
||||||
# modules.json and layouts when ProdDash has ever run from the checkout.
|
# modules.json and layouts when ProdDash has ever run from the checkout.
|
||||||
cp "$REPO_ROOT/config/proddash.json" "$PAYLOAD/config/"
|
cp "$REPO_ROOT/config/proddash.json" "$PAYLOAD/config/"
|
||||||
find "$PAYLOAD" -name '.DS_Store' -delete
|
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
|
fi
|
||||||
|
|
||||||
# ── signing ──────────────────────────────────────────────────────────────
|
# ── signing ──────────────────────────────────────────────────────────────
|
||||||
@@ -165,16 +165,6 @@ fi
|
|||||||
if [[ -f "$CONTENTS/Resources/node/bin/node" ]]; then
|
if [[ -f "$CONTENTS/Resources/node/bin/node" ]]; then
|
||||||
codesign "${SIGN[@]}" --entitlements Resources/node.entitlements "$CONTENTS/Resources/node/bin/node"
|
codesign "${SIGN[@]}" --entitlements Resources/node.entitlements "$CONTENTS/Resources/node/bin/node"
|
||||||
fi
|
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 "${SIGN[@]}" --entitlements Resources/ProdDash.entitlements --identifier "$BUNDLE_ID" "$APP"
|
||||||
codesign --verify --deep --strict "$APP" && echo " signature verifies"
|
codesign --verify --deep --strict "$APP" && echo " signature verifies"
|
||||||
|
|
||||||
|
|||||||
Executable
+121
@@ -0,0 +1,121 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
# Build the Windows bundle — ProdDash-<version>-win-x64.zip: the dashboard,
|
||||||
|
# its own Node runtime and a start script, so nothing has to be installed on
|
||||||
|
# the Windows machine. Unzip, double-click ProdDash.bat, done. It carries no
|
||||||
|
# modules: those come from the admin page's catalog into %APPDATA%\ProdDash.
|
||||||
|
#
|
||||||
|
# ./build.sh build/ProdDash-<version>-win-x64.zip
|
||||||
|
#
|
||||||
|
# Builds on macOS or Linux (zsh, curl, shasum, unzip, zip). Node for Windows
|
||||||
|
# comes from nodejs.org, checksum-verified against its SHASUMS256.txt and
|
||||||
|
# cached in .cache/. There is no Windows launcher app yet: ProdDash.bat keeps
|
||||||
|
# the server running and starts it again when the admin page applies an
|
||||||
|
# update (the server exits with code 75 to ask for that).
|
||||||
|
set -e -u
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
APP_NAME="ProdDash"
|
||||||
|
# The Node the bundle ships — keep in step with launcher/macos/build.sh.
|
||||||
|
NODE_VERSION="v24.21.0"
|
||||||
|
NODE_DIST="node-$NODE_VERSION-win-x64"
|
||||||
|
|
||||||
|
REPO_ROOT="../.."
|
||||||
|
OUT_DIR="build"
|
||||||
|
CACHE_DIR=".cache"
|
||||||
|
|
||||||
|
for tool in curl shasum unzip zip; do
|
||||||
|
command -v "$tool" >/dev/null || { echo "build.sh: $tool not found" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
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; }
|
||||||
|
NAME="$APP_NAME-$VERSION-win-x64"
|
||||||
|
STAGE="$OUT_DIR/$NAME"
|
||||||
|
|
||||||
|
echo "ProdDash $VERSION — building the Windows bundle"
|
||||||
|
rm -rf "$OUT_DIR"
|
||||||
|
mkdir -p "$STAGE/node" "$STAGE/config" "$CACHE_DIR"
|
||||||
|
|
||||||
|
# ── Node for Windows ─────────────────────────────────────────────────────
|
||||||
|
ZIPFILE="$NODE_DIST.zip"
|
||||||
|
if [[ ! -f "$CACHE_DIR/$ZIPFILE" ]]; then
|
||||||
|
echo " fetching Node $NODE_VERSION for Windows (~30 MB, cached for next time)"
|
||||||
|
curl -fsSL --retry 3 -o "$CACHE_DIR/$ZIPFILE.part" "https://nodejs.org/dist/$NODE_VERSION/$ZIPFILE"
|
||||||
|
mv "$CACHE_DIR/$ZIPFILE.part" "$CACHE_DIR/$ZIPFILE"
|
||||||
|
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 ships under ProdDash's name — check it every build.
|
||||||
|
WANT=$(awk -v f="$ZIPFILE" '$2 == f {print $1}' "$SUMS")
|
||||||
|
GOT=$(shasum -a 256 "$CACHE_DIR/$ZIPFILE" | awk '{print $1}')
|
||||||
|
if [[ -z "$WANT" || "$WANT" != "$GOT" ]]; then
|
||||||
|
echo "build.sh: $ZIPFILE does not match nodejs.org's checksum — refusing to ship it" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " embedding Node $NODE_VERSION (node.exe and its licence)"
|
||||||
|
unzip -q -j "$CACHE_DIR/$ZIPFILE" "$NODE_DIST/node.exe" "$NODE_DIST/LICENSE" -d "$STAGE/node"
|
||||||
|
|
||||||
|
# ── ProdDash ─────────────────────────────────────────────────────────────
|
||||||
|
echo " embedding ProdDash $VERSION (no modules — the catalog installs them)"
|
||||||
|
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$STAGE/"
|
||||||
|
cp -R "$REPO_ROOT/public" "$STAGE/"
|
||||||
|
# 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" "$STAGE/config/"
|
||||||
|
find "$STAGE" -name '.DS_Store' -delete
|
||||||
|
|
||||||
|
# ── the start script and the read-me, with Windows line endings ──────────
|
||||||
|
crlf() { awk '{ printf "%s\r\n", $0 }' > "$1"; }
|
||||||
|
|
||||||
|
crlf "$STAGE/ProdDash.bat" <<'BAT'
|
||||||
|
@echo off
|
||||||
|
rem ProdDash - double-click to start. Keep this window open: closing it stops the dashboard.
|
||||||
|
title ProdDash
|
||||||
|
cd /d "%~dp0"
|
||||||
|
rem The server exits with code 75 after applying an update from the admin page; start it again.
|
||||||
|
set PRODDASH_LAUNCHER=1
|
||||||
|
:run
|
||||||
|
"%~dp0node\node.exe" server.js
|
||||||
|
set CODE=%ERRORLEVEL%
|
||||||
|
if %CODE% EQU 75 goto run
|
||||||
|
echo.
|
||||||
|
echo ProdDash stopped (exit code %CODE%). Press any key to close this window.
|
||||||
|
pause >nul
|
||||||
|
BAT
|
||||||
|
|
||||||
|
crlf "$STAGE/README.txt" <<README
|
||||||
|
ProdDash $VERSION for Windows (64-bit)
|
||||||
|
=======================================
|
||||||
|
|
||||||
|
Nothing to install. This folder holds the dashboard and its own copy of Node.
|
||||||
|
|
||||||
|
1. Move this folder somewhere you can write to, for example C:\ProdDash
|
||||||
|
(not Program Files: updates write into this folder).
|
||||||
|
2. Double-click ProdDash.bat. A window opens and stays open while the
|
||||||
|
dashboard runs; closing it stops the dashboard.
|
||||||
|
- If Windows warns about the file, choose More info, then Run anyway.
|
||||||
|
- When Windows Defender Firewall asks about node.exe, allow it on
|
||||||
|
private networks so other screens can reach the dashboard.
|
||||||
|
3. Open http://localhost:24500 on this PC. Other devices on the network use
|
||||||
|
http://<this PC's IP address>:24500. Settings are on the admin page,
|
||||||
|
http://localhost:24500/admin.
|
||||||
|
4. The bundle ships without modules. On the admin page, tick the ones this
|
||||||
|
PC needs under Available modules and press Install selected; each one
|
||||||
|
updates on its own from then on.
|
||||||
|
|
||||||
|
Your settings and layouts live in %APPDATA%\ProdDash, not in this folder,
|
||||||
|
so unzipping a new version or updating never loses them.
|
||||||
|
|
||||||
|
Start with Windows: press Win+R, type shell:startup, press Enter, and put a
|
||||||
|
shortcut to ProdDash.bat in the folder that opens.
|
||||||
|
|
||||||
|
Updates: the admin page's Update now fetches the new version into this
|
||||||
|
folder and restarts the dashboard; the window stays open. A copy of the
|
||||||
|
version it replaced is kept for a short while in case the new one will not
|
||||||
|
start.
|
||||||
|
README
|
||||||
|
|
||||||
|
# ── zip ──────────────────────────────────────────────────────────────────
|
||||||
|
echo " zipping"
|
||||||
|
(cd "$OUT_DIR" && zip -q -r -X "$NAME.zip" "$NAME")
|
||||||
|
echo "built $OUT_DIR/$NAME.zip ($(du -sh "$OUT_DIR/$NAME.zip" | awk '{print $1}'))"
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
/* PCO Plan — client part.
|
/* PCO Plan — client part.
|
||||||
|
|
||||||
The module presents two tiles and this is the entry for both: the Teams
|
The module presents three tiles and this is the entry for all of them: the
|
||||||
tile (picker entry "teams") is built by teams-client.js, everything below
|
Teams tile (picker entry "teams") is built by teams-client.js and the Song
|
||||||
draws the Plan tile.
|
Key tile ("key") by key-client.js; everything below draws the Plan tile.
|
||||||
|
|
||||||
The server part reads Planning Center and (optionally) follows the
|
The server part reads Planning Center and (optionally) follows the
|
||||||
ProPresenter module's live item; this tile only renders the plan it is
|
ProPresenter module's live item; this tile only renders the plan it is
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
the connection settings. */
|
the connection settings. */
|
||||||
|
|
||||||
import createTeams from "./teams-client.js";
|
import createTeams from "./teams-client.js";
|
||||||
|
import createKey from "./key-client.js";
|
||||||
|
import { prettyKey } from "./key-notation.js";
|
||||||
|
|
||||||
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>';
|
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>';
|
||||||
|
|
||||||
@@ -21,8 +23,9 @@ const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
|
|||||||
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
|
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
|
||||||
|
|
||||||
export default function create({ root, moduleApi }) {
|
export default function create({ root, moduleApi }) {
|
||||||
// Tiles added as "Teams" carry that variant and have their own settings.
|
// Each picker entry carries its variant and its own settings.
|
||||||
if (moduleApi.variant === "teams") return createTeams({ root, moduleApi });
|
if (moduleApi.variant === "teams") return createTeams({ root, moduleApi });
|
||||||
|
if (moduleApi.variant === "key") return createKey({ root, moduleApi });
|
||||||
return createPlan({ root, moduleApi });
|
return createPlan({ root, moduleApi });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +370,8 @@ function createPlan({ root, moduleApi }) {
|
|||||||
if (on('showKey') && item.keyName) {
|
if (on('showKey') && item.keyName) {
|
||||||
const key = document.createElement('span');
|
const key = document.createElement('span');
|
||||||
key.className = 'pp-key';
|
key.className = 'pp-key';
|
||||||
key.textContent = item.keyName;
|
// B♭, F♯m — the same notation the Song Key tile uses.
|
||||||
|
key.textContent = prettyKey(item.keyName);
|
||||||
titleLine.appendChild(key);
|
titleLine.appendChild(key);
|
||||||
}
|
}
|
||||||
if (on('showNowIndicator')) {
|
if (on('showNowIndicator')) {
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/* PCO Plan — the Song Key tile.
|
||||||
|
|
||||||
|
One thing, big: the key of the plan item on screen. It reads the same
|
||||||
|
streamed state the Plan tile does, so it needs no connection of its own;
|
||||||
|
which item is current comes from the ProPresenter module, as ever. */
|
||||||
|
|
||||||
|
import { parseKey, prettyKey } from "./key-notation.js";
|
||||||
|
|
||||||
|
export default function createKey({ root, moduleApi }) {
|
||||||
|
let state = null;
|
||||||
|
let stream = null;
|
||||||
|
let feedOffline = false;
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="pk-wrap">
|
||||||
|
<div class="pk-key"></div>
|
||||||
|
<div class="pk-name"></div>
|
||||||
|
<div class="pk-next"></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const wrap = root.querySelector('.pk-wrap');
|
||||||
|
const keyEl = root.querySelector('.pk-key');
|
||||||
|
const nameEl = root.querySelector('.pk-name');
|
||||||
|
const nextEl = root.querySelector('.pk-next');
|
||||||
|
|
||||||
|
const cfg = () => moduleApi.instanceSettings || {};
|
||||||
|
const on = (key) => cfg()[key] !== false; // unset = shown
|
||||||
|
|
||||||
|
/* ── the item on screen, and the one after it ───────────────────── */
|
||||||
|
|
||||||
|
function items() {
|
||||||
|
return Array.isArray(state?.items) ? state.items : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentItem() {
|
||||||
|
const id = state?.live?.following ? state.live.currentItemId : '';
|
||||||
|
return id ? items().find((it) => it.id === id) || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The next item that actually has a key — a talk between two songs is skipped. */
|
||||||
|
function nextKeyed(after) {
|
||||||
|
const list = items();
|
||||||
|
const from = after ? list.findIndex((it) => it.id === after.id) + 1 : 0;
|
||||||
|
if (from <= 0) return null;
|
||||||
|
for (let i = from; i < list.length; i += 1) {
|
||||||
|
if (list[i].keyName) return list[i];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleOf(item) {
|
||||||
|
return item ? (item.song?.title || item.title || '') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── render ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
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 item = currentItem();
|
||||||
|
if (!state.live?.following) return moduleApi.setStatus('ok', 'Not following ProPresenter — no item is current');
|
||||||
|
// The key on screen stays — the band still needs it — but a tile must
|
||||||
|
// never look confidently live while the feed that chose it is gone.
|
||||||
|
if (!state.live.available) {
|
||||||
|
return moduleApi.setStatus('error', `${state.live.reason || 'ProPresenter feed unavailable'} — showing the last item`);
|
||||||
|
}
|
||||||
|
if (!state.live.ppReachable) return moduleApi.setStatus('error', 'ProPresenter unreachable — showing the last item');
|
||||||
|
if (!item) return moduleApi.setStatus('ok', 'Waiting for an item to go live');
|
||||||
|
moduleApi.setStatus('ok', `${titleOf(item)}${item.keyName ? ` — ${prettyKey(item.keyName)}` : ' — no key in the plan'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key is a note, so it is worth setting properly: "Bb" and "F#m" read
|
||||||
|
* better with the accidental raised and the minor kept small.
|
||||||
|
*/
|
||||||
|
function drawKey(text) {
|
||||||
|
keyEl.innerHTML = '';
|
||||||
|
const parts = parseKey(text);
|
||||||
|
if (!parts) {
|
||||||
|
keyEl.textContent = text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
keyEl.appendChild(document.createTextNode(parts.note));
|
||||||
|
if (parts.accidental) {
|
||||||
|
const acc = document.createElement('span');
|
||||||
|
acc.className = 'pk-accidental';
|
||||||
|
acc.textContent = parts.accidental;
|
||||||
|
keyEl.appendChild(acc);
|
||||||
|
}
|
||||||
|
if (parts.quality) {
|
||||||
|
const tail = document.createElement('span');
|
||||||
|
tail.className = 'pk-quality';
|
||||||
|
tail.textContent = parts.quality;
|
||||||
|
keyEl.appendChild(tail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
wrap.classList.toggle('is-offline', feedOffline);
|
||||||
|
const live = state?.live;
|
||||||
|
wrap.classList.toggle('is-stale', Boolean(live?.following && (!live.available || !live.ppReachable)));
|
||||||
|
const color = String(cfg().keyColor || '').trim();
|
||||||
|
wrap.style.setProperty('--pk-color', /^[#a-zA-Z0-9(),.%\s-]+$/.test(color) && color ? color : '#4ea1ff');
|
||||||
|
renderStatus();
|
||||||
|
|
||||||
|
const item = currentItem();
|
||||||
|
const key = item?.keyName || '';
|
||||||
|
wrap.classList.toggle('has-key', Boolean(key));
|
||||||
|
|
||||||
|
if (!state || !state.configured || !state.serviceTypeSet) {
|
||||||
|
drawKey('—');
|
||||||
|
nameEl.textContent = state ? 'Set up PCO Plan in Admin' : 'Connecting…';
|
||||||
|
nextEl.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!item) {
|
||||||
|
drawKey('—');
|
||||||
|
nameEl.textContent = state.live?.following ? 'Nothing live' : 'Not following ProPresenter';
|
||||||
|
nextEl.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawKey(key || '—');
|
||||||
|
nameEl.textContent = on('showSongName') ? titleOf(item) : '';
|
||||||
|
nameEl.hidden = !nameEl.textContent;
|
||||||
|
|
||||||
|
const next = on('showNextKey') ? nextKeyed(item) : null;
|
||||||
|
nextEl.textContent = next ? `Next: ${prettyKey(next.keyName)} · ${titleOf(next)}` : '';
|
||||||
|
nextEl.hidden = !nextEl.textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── live feed ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function accept(payload) {
|
||||||
|
if (!payload || typeof payload !== 'object' || !payload.state) return;
|
||||||
|
state = payload.state;
|
||||||
|
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 */ }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── lifecycle ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
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();
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
stream?.close();
|
||||||
|
stream = null;
|
||||||
|
root.innerHTML = '';
|
||||||
|
},
|
||||||
|
onConfigChange() {
|
||||||
|
// Admin holds the connection only; the server re-inits and re-sends.
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/* PCO Plan — how a song key is written.
|
||||||
|
|
||||||
|
Planning Center stores keys as typed: "Bb", "F#m", "Ab". Every tile in this
|
||||||
|
module shows them as notes instead — B♭, F♯m, A♭ — so the plan, the badge
|
||||||
|
on an item and the Song Key tile all agree. Shared rather than repeated
|
||||||
|
because the day one of them changes, they all should. */
|
||||||
|
|
||||||
|
/** "F#m" → { note: 'F', accidental: '♯', quality: 'm' }; anything odd → null. */
|
||||||
|
export function parseKey(raw) {
|
||||||
|
const match = /^([A-G])\s*([#b♯♭]?)\s*(.*)$/.exec(String(raw || '').trim());
|
||||||
|
if (!match) return null;
|
||||||
|
const [, note, accidental, quality] = match;
|
||||||
|
return {
|
||||||
|
note,
|
||||||
|
accidental: accidental === '#' ? '♯' : accidental === 'b' ? '♭' : accidental,
|
||||||
|
quality: quality.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same thing as one string, for a badge or a caption. */
|
||||||
|
export function prettyKey(raw) {
|
||||||
|
const parts = parseKey(raw);
|
||||||
|
if (!parts) return String(raw || '').trim();
|
||||||
|
return `${parts.note}${parts.accidental}${parts.quality}`;
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"id": "pco-plan",
|
"id": "pco-plan",
|
||||||
"name": "PCO Plan",
|
"name": "PCO Plan",
|
||||||
"version": "1.8.0",
|
"version": "1.9.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Order of service and team assignments from Planning Center Services",
|
"description": "Order of service, team assignments and the song key from Planning Center Services",
|
||||||
"provides": [
|
"provides": [
|
||||||
"timers"
|
"timers"
|
||||||
],
|
],
|
||||||
@@ -621,6 +621,36 @@
|
|||||||
"columns": 2
|
"columns": 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "key",
|
||||||
|
"name": "Song Key",
|
||||||
|
"description": "The key of the item on screen, big",
|
||||||
|
"defaultSize": {
|
||||||
|
"w": 2,
|
||||||
|
"h": 2
|
||||||
|
},
|
||||||
|
"minSize": {
|
||||||
|
"w": 1,
|
||||||
|
"h": 1
|
||||||
|
},
|
||||||
|
"instanceSchema": {
|
||||||
|
"keyColor": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Key",
|
||||||
|
"default": "#4ea1ff"
|
||||||
|
},
|
||||||
|
"showSongName": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Song name",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"showNextKey": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Next key",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,7 +206,9 @@
|
|||||||
|
|
||||||
[data-module="pco-plan"] .pp-now-tag {
|
[data-module="pco-plan"] .pp-now-tag {
|
||||||
display: none;
|
display: none;
|
||||||
padding: 0.05em 0.45em;
|
/* Optically centred like the section chips: uppercase has no descenders,
|
||||||
|
so the capitals sat 0.59px low in the pill. */
|
||||||
|
padding: 0.018em 0.45em 0.082em;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--pp-current);
|
background: var(--pp-current);
|
||||||
color: var(--bg);
|
color: var(--bg);
|
||||||
@@ -635,25 +637,110 @@
|
|||||||
|
|
||||||
[data-module="pco-plan"] .pp-abs-sec {
|
[data-module="pco-plan"] .pp-abs-sec {
|
||||||
flex: none;
|
flex: none;
|
||||||
padding: 0.1em 0.5em;
|
/* Uppercase text has no descenders, so the font's descent allowance below
|
||||||
border-radius: 999px;
|
the baseline leaves the caps sitting high in the box — measured at 0.9px
|
||||||
border: 1px solid var(--border);
|
too much headroom. The padding is a touch shallower on top and deeper
|
||||||
|
below to centre the letters themselves; in em, so it holds at any text
|
||||||
|
size. */
|
||||||
|
padding: 0.095em 0.55em 0.185em;
|
||||||
|
/* The same rounded square, tinted ground and transparent border the AbleSet
|
||||||
|
module gives its own section pills, so the two read as one family. */
|
||||||
|
border-radius: 0.35em;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: color-mix(in srgb, var(--text) 8%, var(--panel));
|
||||||
font-size: 0.72em;
|
font-size: 0.72em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
transition: color 0.2s ease, border-color 0.2s ease;
|
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
|
||||||
}
|
}
|
||||||
[data-module="pco-plan"] .pp-abs-sec.is-done { opacity: 0.45; }
|
[data-module="pco-plan"] .pp-abs-sec.is-done { opacity: 0.45; }
|
||||||
|
|
||||||
/* The section now playing fills left to right as it goes, like the row. */
|
/* The section now playing fills left to right as it goes, like the row. */
|
||||||
|
/* The section playing keeps its own ground and fills across it, so the
|
||||||
|
progress reads against the chip rather than against the row behind it. */
|
||||||
[data-module="pco-plan"] .pp-abs-sec.is-now {
|
[data-module="pco-plan"] .pp-abs-sec.is-now {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-color: color-mix(in srgb, var(--pp-current) 60%, transparent);
|
border-color: color-mix(in srgb, var(--pp-current) 60%, transparent);
|
||||||
background:
|
background:
|
||||||
linear-gradient(90deg,
|
linear-gradient(90deg,
|
||||||
color-mix(in srgb, var(--pp-current) 40%, transparent) var(--pp-sec-fill, 0%),
|
color-mix(in srgb, var(--pp-current) 45%, var(--panel)) var(--pp-sec-fill, 0%),
|
||||||
transparent var(--pp-sec-fill, 0%));
|
color-mix(in srgb, var(--text) 8%, var(--panel)) var(--pp-sec-fill, 0%));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── the Song Key tile ────────────────────────────────────────────────
|
||||||
|
One value, as large as the tile allows. Sizes come from the tile itself
|
||||||
|
(container units), like the AbleSet module's single readouts, so a small
|
||||||
|
tile and a wall-sized one both read properly. */
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pk-wrap {
|
||||||
|
--pk-color: #4ea1ff;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
container-type: size;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.2em;
|
||||||
|
padding: 4%;
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pk-key {
|
||||||
|
font-size: clamp(18px, 46cqmin, 260px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pk-wrap.has-key .pk-key { color: var(--pk-color); }
|
||||||
|
|
||||||
|
/* A key is a note, but it has to read as one word at a glance from the back
|
||||||
|
of a room: the accidental and the quality sit on the same line as the
|
||||||
|
letter, only smaller. Raising the accidental looked like two stacked
|
||||||
|
characters at tile sizes. */
|
||||||
|
[data-module="pco-plan"] .pk-accidental {
|
||||||
|
font-size: 0.68em;
|
||||||
|
margin-left: 0.01em;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pk-quality {
|
||||||
|
font-size: 0.5em;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-left: 0.05em;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pk-name {
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: clamp(9px, 9cqmin, 30px);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pk-name[hidden] { display: none; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pk-next {
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: clamp(8px, 6.5cqmin, 20px);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pk-next[hidden] { display: none; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pk-wrap.is-offline { opacity: 0.6; }
|
||||||
|
/* The feed that picks the item is gone: the key stays readable, but faded,
|
||||||
|
so nobody trusts it as current without looking at the status dot. */
|
||||||
|
[data-module="pco-plan"] .pk-wrap.is-stale .pk-key { opacity: 0.55; }
|
||||||
|
[data-module="pco-plan"] .pk-wrap.is-stale .pk-name,
|
||||||
|
[data-module="pco-plan"] .pk-wrap.is-stale .pk-next { opacity: 0.6; }
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ export default function create({ root, moduleApi }) {
|
|||||||
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
|
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
|
||||||
applyInstanceSettings();
|
applyInstanceSettings();
|
||||||
}
|
}
|
||||||
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
const minusBtn = moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
||||||
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
|
const plusBtn = moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
|
||||||
|
|
||||||
// icon and tooltip track the current direction — applyInstanceSettings sets both
|
// icon and tooltip track the current direction — applyInstanceSettings sets both
|
||||||
const flowBtn = moduleApi.header.addButton({
|
const flowBtn = moduleApi.header.addButton({
|
||||||
@@ -95,7 +95,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
moduleApi.header.addButton({
|
const latestBtn = moduleApi.header.addButton({
|
||||||
icon: JUMP_SVG,
|
icon: JUMP_SVG,
|
||||||
title: 'Latest',
|
title: 'Latest',
|
||||||
onClick() {
|
onClick() {
|
||||||
@@ -112,6 +112,82 @@ export default function create({ root, moduleApi }) {
|
|||||||
});
|
});
|
||||||
clearBtn.classList.add('pt-clear');
|
clearBtn.classList.add('pt-clear');
|
||||||
|
|
||||||
|
/* ── a narrow tile: the bar keeps Channels, the rest fold into ⋯ ──
|
||||||
|
Seven controls need ~175 px, and the shell clips whatever the bar
|
||||||
|
cannot fit — on a 3-column tile among many, or one squeezed by the
|
||||||
|
fullscreen fit, that used to take the channel filter with it. Below
|
||||||
|
COMPACT_PX the six secondary controls hide and a single ⋯ menu offers
|
||||||
|
the same actions, so the filter button is the last thing to go. */
|
||||||
|
const MORE_SVG = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>';
|
||||||
|
const COMPACT_PX = 380; // tile width below which the full bar no longer fits
|
||||||
|
|
||||||
|
let moreMenuEl = null;
|
||||||
|
const moreMenu = moduleApi.header.addMenu({
|
||||||
|
icon: MORE_SVG,
|
||||||
|
title: 'More',
|
||||||
|
build(menu) {
|
||||||
|
menu.classList.add('pt-menu');
|
||||||
|
moreMenuEl = menu;
|
||||||
|
rebuildMoreMenu();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const moreWrap = moreMenu.button.parentElement; // the shell's .tile-dd
|
||||||
|
const secondaryBtns = [timeBtn, minusBtn, plusBtn, flowBtn, latestBtn, clearBtn];
|
||||||
|
|
||||||
|
function addMoreItem(label, { on = null, onClick } = {}) {
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.className = 'pt-channel-item' + (on ? ' on' : '');
|
||||||
|
if (on !== null) {
|
||||||
|
item.setAttribute('role', 'menuitemcheckbox');
|
||||||
|
item.setAttribute('aria-checked', String(on));
|
||||||
|
} else {
|
||||||
|
item.setAttribute('role', 'menuitem');
|
||||||
|
}
|
||||||
|
const check = document.createElement('span');
|
||||||
|
check.className = 'check';
|
||||||
|
check.textContent = '✓';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'name';
|
||||||
|
name.textContent = label;
|
||||||
|
item.append(check, name);
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation(); // the rebuild detaches this item — keep the menu open
|
||||||
|
onClick();
|
||||||
|
});
|
||||||
|
moreMenuEl.appendChild(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildMoreMenu() {
|
||||||
|
if (!moreMenuEl) return;
|
||||||
|
moreMenuEl.innerHTML = '';
|
||||||
|
const s = moduleApi.instanceSettings;
|
||||||
|
addMoreItem('Timestamps', { on: Boolean(s.showTimes), onClick: () => timeBtn.click() });
|
||||||
|
addMoreItem('Newest first', { on: newestFirst(), onClick: () => flowBtn.click() });
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'pt-menu-divider';
|
||||||
|
moreMenuEl.appendChild(divider);
|
||||||
|
addMoreItem('Smaller text', { onClick: () => bumpTextSize(-2) });
|
||||||
|
addMoreItem('Larger text', { onClick: () => bumpTextSize(2) });
|
||||||
|
addMoreItem('Latest', { onClick: () => { moreMenu.close(); latestBtn.click(); } });
|
||||||
|
addMoreItem('Clear', { onClick: () => { moreMenu.close(); clearView(); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
let compact = null; // unknown until the first measurement
|
||||||
|
function applyCompact(width) {
|
||||||
|
const next = width > 0 && width < COMPACT_PX;
|
||||||
|
if (next === compact) return;
|
||||||
|
compact = next;
|
||||||
|
for (const btn of secondaryBtns) btn.hidden = compact;
|
||||||
|
moreWrap.hidden = !compact;
|
||||||
|
if (!compact) moreMenu.close();
|
||||||
|
}
|
||||||
|
// the tile's own body tells the width; onResize only knows grid cells
|
||||||
|
const sizeWatch = new ResizeObserver((entries) => {
|
||||||
|
applyCompact(entries[0]?.contentRect.width || root.clientWidth);
|
||||||
|
});
|
||||||
|
sizeWatch.observe(root);
|
||||||
|
applyCompact(root.clientWidth);
|
||||||
|
|
||||||
function applyInstanceSettings() {
|
function applyInstanceSettings() {
|
||||||
const s = moduleApi.instanceSettings;
|
const s = moduleApi.instanceSettings;
|
||||||
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
||||||
@@ -126,6 +202,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
flowBtn.classList.toggle('active', up);
|
flowBtn.classList.toggle('active', up);
|
||||||
flowBtn.title = up ? 'Newest first' : 'Oldest first';
|
flowBtn.title = up ? 'Newest first' : 'Oldest first';
|
||||||
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
|
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
|
||||||
|
rebuildMoreMenu(); // its checkmarks mirror the buttons
|
||||||
}
|
}
|
||||||
applyInstanceSettings();
|
applyInstanceSettings();
|
||||||
|
|
||||||
@@ -415,6 +492,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
},
|
},
|
||||||
stop() {
|
stop() {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
|
sizeWatch.disconnect();
|
||||||
stream?.close();
|
stream?.close();
|
||||||
root.innerHTML = ''; // header controls are removed by the shell
|
root.innerHTML = ''; // header controls are removed by the shell
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "prodcom-transcript",
|
"id": "prodcom-transcript",
|
||||||
"name": "ProdCom",
|
"name": "ProdCom",
|
||||||
"version": "1.3.2",
|
"version": "1.3.3",
|
||||||
"proddash": ">=1.1.0",
|
"proddash": ">=1.1.0",
|
||||||
"description": "Live ProdCom transcript",
|
"description": "Live ProdCom transcript",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
@@ -20,9 +20,13 @@
|
|||||||
}
|
}
|
||||||
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
|
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* narrow tile: the secondary controls fold into the ⋯ menu (client.js) */
|
||||||
|
[data-module="prodcom-transcript"] .tile-btn[hidden],
|
||||||
|
[data-module="prodcom-transcript"] .tile-dd[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 hangs off the body under a [data-module] host, so scoping still applies. */
|
||||||
|
|
||||||
[data-module="prodcom-transcript"] .pt-channel-item {
|
[data-module="prodcom-transcript"] .pt-channel-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
BIN
Binary file not shown.
Binary file not shown.
@@ -70,6 +70,7 @@ const DEFAULTS = {
|
|||||||
showDetail: true,
|
showDetail: true,
|
||||||
showSeconds: true,
|
showSeconds: true,
|
||||||
leadingZeros: false,
|
leadingZeros: false,
|
||||||
|
showLoops: false,
|
||||||
clock24h: true,
|
clock24h: true,
|
||||||
clockSeconds: true,
|
clockSeconds: true,
|
||||||
clockDate: false,
|
clockDate: false,
|
||||||
@@ -384,7 +385,12 @@ export default function create({ root, moduleApi }) {
|
|||||||
for (const source of state.sources) {
|
for (const source of state.sources) {
|
||||||
for (const timer of source.timers) {
|
for (const timer of source.timers) {
|
||||||
if (keyOf(source, timer) !== solo) continue;
|
if (keyOf(source, timer) !== solo) continue;
|
||||||
return [{ id: source.id, source, items: [{ key: solo, source, timer }] }];
|
// A solo video tile with looping media hidden must not sit empty:
|
||||||
|
// it shows a quiet card instead of a countdown nobody should read.
|
||||||
|
const shown = timer.looping && pref('showLoops') !== true
|
||||||
|
? { ...timer, state: 'idle', remainingMs: null, elapsedMs: null, startedAt: null, asOf: null, status: { text: 'Looping', tone: 'muted' } }
|
||||||
|
: timer;
|
||||||
|
return [{ id: source.id, source, items: [{ key: solo, source, timer: shown }] }];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The timer this tile was added for is not offered right now. Nothing is
|
// The timer this tile was added for is not offered right now. Nothing is
|
||||||
@@ -398,7 +404,11 @@ export default function create({ root, moduleApi }) {
|
|||||||
const items = [];
|
const items = [];
|
||||||
for (const timer of source.timers) {
|
for (const timer of source.timers) {
|
||||||
const key = keyOf(source, timer);
|
const key = keyOf(source, timer);
|
||||||
if (!hidden.has(key)) items.push({ key, source, timer });
|
if (hidden.has(key)) continue;
|
||||||
|
// A looping background's time means nothing — the card sits out
|
||||||
|
// while a loop plays (per tile: the "Looping videos" switch).
|
||||||
|
if (timer.looping && pref('showLoops') !== true) continue;
|
||||||
|
items.push({ key, source, timer });
|
||||||
}
|
}
|
||||||
// Every timer of a live source hidden on purpose: nothing to say.
|
// Every timer of a live source hidden on purpose: nothing to say.
|
||||||
if (!items.length && source.timers.length) continue;
|
if (!items.length && source.timers.length) continue;
|
||||||
@@ -666,7 +676,9 @@ export default function create({ root, moduleApi }) {
|
|||||||
card.mono = null;
|
card.mono = null;
|
||||||
}
|
}
|
||||||
text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
|
text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
|
||||||
tone = toneFor(timer, value);
|
// A shown loop never warns or overruns — it wraps at zero by design,
|
||||||
|
// and a background flashing red every pass would cry wolf all service.
|
||||||
|
tone = timer.looping ? (timer.state === 'running' ? 'ok' : 'muted') : toneFor(timer, value);
|
||||||
statusText = timer.status?.text || defaultStatus(timer, value, tone);
|
statusText = timer.status?.text || defaultStatus(timer, value, tone);
|
||||||
statusTone = timer.status?.tone || tone;
|
statusTone = timer.status?.tone || tone;
|
||||||
detailText = timer.detail || '';
|
detailText = timer.detail || '';
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
/**
|
/**
|
||||||
* Built-in LTC listener — capture child + in-process decode.
|
* Built-in LTC listener — capture child + in-process decode.
|
||||||
*
|
*
|
||||||
* Spawns the module's native capture tool (ltc-capture, compiled from
|
* Spawns the module's native capture tool (bin/ltc-capture-macos-<arch>,
|
||||||
* ltc-capture.swift beside it) for the admin-configured audio device, reads
|
* shipped prebuilt; source in native/) for the admin-configured audio device, reads
|
||||||
* the interleaved s16le stream, extracts the configured channel, and decodes
|
* the interleaved s16le stream, extracts the configured channel, and decodes
|
||||||
* SMPTE LTC with ltc.js — all inside the module server. Reports upward via
|
* SMPTE LTC with ltc.js — all inside the module server. Reports upward via
|
||||||
* onUpdate roughly 4×/s:
|
* onUpdate roughly 4×/s:
|
||||||
@@ -16,17 +16,13 @@
|
|||||||
* shows (short — "Not configured — see Admin"), `detail` is the full story
|
* shows (short — "Not configured — see Admin"), `detail` is the full story
|
||||||
* for the admin health line and the log.
|
* for the admin health line and the log.
|
||||||
*
|
*
|
||||||
* The capture binary is per-machine (gitignored), and a catalog install or
|
* The capture tool ships prebuilt inside the module, one binary per
|
||||||
* update ships source only — the update even wipes a previously built
|
* platform under bin/ (module guide, "Native helpers"): nothing is ever
|
||||||
* binary. ensureCaptureBinary() therefore rebuilds it with swiftc in the
|
* built on the machine running ProdDash. captureTool() picks the binary for
|
||||||
* background as part of coming up: kicked when the LTC source starts and by
|
* this platform; when there is none, timecode reports itself off and the
|
||||||
* the admin device picker, never on the event loop (a blocking build once
|
* rest of the module carries on. A dead capture child is restarted with
|
||||||
* froze the whole server — see spl-meter's history), built to a temp name
|
* backoff — fast for a crash, slow for config-shaped failures (unknown
|
||||||
* and renamed so a killed build can't leave a half-written "binary", and
|
* device, ambiguity) that never fix themselves.
|
||||||
* rebuilt when a module update leaves the source newer than the binary.
|
|
||||||
* A dead capture child is restarted with backoff — fast for a crash, slow
|
|
||||||
* for config-shaped failures (unknown device, ambiguity) that never fix
|
|
||||||
* themselves.
|
|
||||||
*
|
*
|
||||||
* macOS gotcha this file can't fix: microphone permission. A ProdDash
|
* macOS gotcha this file can't fix: microphone permission. A ProdDash
|
||||||
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
|
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
|
||||||
@@ -51,107 +47,43 @@ const RUNNING_WINDOW_MS = 400;
|
|||||||
/** stderr line the capture prints once the device is open. */
|
/** stderr line the capture prints once the device is open. */
|
||||||
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
|
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
|
||||||
|
|
||||||
/* ── the capture tool: build it when the install didn't bring one ──────── */
|
/* ── the capture tool: prebuilt, one binary per platform ───────────────── */
|
||||||
|
|
||||||
/** Tile-facing words (short); the detail goes to admin health and the log. */
|
/** Tile-facing words (short); the detail goes to admin health and the log. */
|
||||||
const TOOL_BUILDING = 'Capture tool building…';
|
|
||||||
const TOOL_MISSING = 'Capture tool unavailable — see Admin';
|
const TOOL_MISSING = 'Capture tool unavailable — see Admin';
|
||||||
const BUILD_HELP = 'LTC capture tool could not be built here — build it on any Mac of the '
|
|
||||||
+ 'same architecture with `swiftc -O -o ltc-capture ltc-capture.swift` (both files are in '
|
|
||||||
+ 'the module folder) and copy the binary next to ltc-capture.swift';
|
|
||||||
|
|
||||||
let building = false;
|
/** What the module ships in bin/, by platform and architecture. */
|
||||||
let buildFailed = false;
|
const HELPERS = {
|
||||||
|
darwin: { arm64: 'ltc-capture-macos-arm64', x64: 'ltc-capture-macos-x86_64' },
|
||||||
|
win32: { x64: 'ltc-capture-win-x64.exe' },
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make sure the capture tool exists, building it in the background when it
|
* The capture tool for this machine. It is never built here and never
|
||||||
* does not — module scope, so the LTC source can kick it the moment it
|
* looked for anywhere else: the module ships it, or timecode is off on this
|
||||||
* starts and the admin device picker can kick it before any device is
|
* platform and says so. `ready` means a usable binary at `bin`; otherwise
|
||||||
* configured (a listener-only build would deadlock: no tool → no device
|
* `error` is the short tile text and `detail` names the platform.
|
||||||
* list → no device → no listener). A catalog install ships source only and
|
|
||||||
* a catalog update deletes the module folder (old binary included), so this
|
|
||||||
* IS the install step for the tool.
|
|
||||||
*
|
*
|
||||||
* Never blocks (a synchronous build once held the whole server's event loop
|
* @returns {{ready:boolean, bin:string, error:string, detail:string}}
|
||||||
* — every module — for the duration). Builds to a temp name and renames, so
|
|
||||||
* a killed or crashed build can't leave a truncated file that looks like a
|
|
||||||
* ready binary. A binary older than the source (a module update over an
|
|
||||||
* existing folder) keeps serving while a replacement builds behind it.
|
|
||||||
*
|
|
||||||
* @returns {{ready:boolean, error:string, detail:string}} `ready` = a binary
|
|
||||||
* is usable right now; otherwise `error` is the short tile text and
|
|
||||||
* `detail` the full one.
|
|
||||||
*/
|
*/
|
||||||
function ensureCaptureBinary({ moduleDir, log, onReady }) {
|
function captureTool(moduleDir, platform = process.platform, arch = process.arch) {
|
||||||
const bin = path.join(moduleDir, 'ltc-capture');
|
const name = HELPERS[platform]?.[arch] || '';
|
||||||
const src = bin + '.swift';
|
const bin = name ? path.join(moduleDir, 'bin', name) : '';
|
||||||
const ok = { ready: true, error: '', detail: '' };
|
if (bin && fs.existsSync(bin)) return { ready: true, bin, error: '', detail: '' };
|
||||||
let haveBin = false;
|
const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`;
|
||||||
let stale = false;
|
return {
|
||||||
try {
|
|
||||||
haveBin = fs.existsSync(bin);
|
|
||||||
if (haveBin && fs.existsSync(src)) stale = fs.statSync(src).mtimeMs > fs.statSync(bin).mtimeMs;
|
|
||||||
} catch { /* treat as missing */ }
|
|
||||||
if (haveBin && !stale) return ok;
|
|
||||||
if (!fs.existsSync(src)) {
|
|
||||||
return haveBin ? ok : { ready: false, error: TOOL_MISSING, detail: 'ltc-capture.swift is missing from the module folder' };
|
|
||||||
}
|
|
||||||
const notYet = haveBin ? ok : {
|
|
||||||
ready: false,
|
ready: false,
|
||||||
error: building || !buildFailed ? TOOL_BUILDING : TOOL_MISSING,
|
bin: '',
|
||||||
detail: building || !buildFailed ? 'Building the LTC capture tool — ready in a moment' : BUILD_HELP,
|
error: TOOL_MISSING,
|
||||||
|
detail: `The module ships no LTC capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — timecode is off on this machine`,
|
||||||
};
|
};
|
||||||
if (building || buildFailed) return notYet;
|
|
||||||
|
|
||||||
building = true;
|
|
||||||
const tmp = bin + '.building';
|
|
||||||
log(stale ? 'ltc-capture is older than its source — rebuilding it in the background'
|
|
||||||
: 'ltc-capture binary missing — building it in the background');
|
|
||||||
const build = spawn('swiftc', ['-O', '-o', tmp, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
||||||
let buildErr = '';
|
|
||||||
build.stderr.setEncoding('utf8');
|
|
||||||
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
|
||||||
const giveUp = setTimeout(() => {
|
|
||||||
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
|
||||||
}, 300000);
|
|
||||||
giveUp.unref?.();
|
|
||||||
build.on('error', (err) => {
|
|
||||||
clearTimeout(giveUp);
|
|
||||||
building = false;
|
|
||||||
buildFailed = true;
|
|
||||||
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
|
|
||||||
log(`swiftc could not be run: ${err.message}`);
|
|
||||||
});
|
|
||||||
build.on('exit', (code) => {
|
|
||||||
clearTimeout(giveUp);
|
|
||||||
building = false;
|
|
||||||
if (code === 0 && fs.existsSync(tmp)) {
|
|
||||||
try {
|
|
||||||
fs.renameSync(tmp, bin);
|
|
||||||
} catch (err) {
|
|
||||||
buildFailed = true;
|
|
||||||
log(`ltc-capture built but could not be moved into place: ${err.message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
buildFailed = false;
|
|
||||||
log('ltc-capture built');
|
|
||||||
if (typeof onReady === 'function') onReady();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
buildFailed = true;
|
|
||||||
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
|
|
||||||
log(`ltc-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
|
||||||
});
|
|
||||||
return notYet;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{device:string, channel:number, moduleDir:string, log:Function,
|
* @param {{device:string, channel:number, moduleDir:string, log:Function,
|
||||||
* onUpdate:(u:object)=>void}} opts
|
* onUpdate:(u:object)=>void}} opts
|
||||||
* @returns {{stop:()=>void}}
|
* @returns {{stop:()=>void}}
|
||||||
*/
|
*/
|
||||||
function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||||
const bin = path.join(moduleDir, 'ltc-capture');
|
|
||||||
let child = null;
|
let child = null;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let restartTimer = null;
|
let restartTimer = null;
|
||||||
@@ -181,22 +113,11 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
const tool = ensureCaptureBinary({
|
const tool = captureTool(moduleDir);
|
||||||
moduleDir,
|
|
||||||
log,
|
|
||||||
onReady() {
|
|
||||||
// The build this start() was waiting on landed — go now.
|
|
||||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!tool.ready) {
|
if (!tool.ready) {
|
||||||
|
// No binary for this platform: say so and look again later — a module
|
||||||
|
// update remounts us anyway, this is only belt and braces.
|
||||||
report({ error: tool.error, detail: tool.detail });
|
report({ error: tool.error, detail: tool.detail });
|
||||||
// onReady is only the FAST path (a successful build restarts us at
|
|
||||||
// once). The slow retry must stay armed regardless: a failed build
|
|
||||||
// never calls back, and a binary can also arrive from outside — scp'd
|
|
||||||
// in on a machine whose toolchain can't build (AB's broken CLT).
|
|
||||||
// Waiting on onReady alone deadlocked exactly that way in production:
|
|
||||||
// "Capture tool building…" forever, with a working binary on disk.
|
|
||||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -206,7 +127,7 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
let decoder = null;
|
let decoder = null;
|
||||||
let stderrTail = '';
|
let stderrTail = '';
|
||||||
|
|
||||||
child = spawn(bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] });
|
child = spawn(tool.bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
|
||||||
child.stderr.setEncoding('utf8');
|
child.stderr.setEncoding('utf8');
|
||||||
child.stderr.on('data', (text) => {
|
child.stderr.on('data', (text) => {
|
||||||
@@ -298,4 +219,4 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createLtcListener, ensureCaptureBinary };
|
module.exports = { createLtcListener, captureTool };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-timers",
|
"id": "propresenter-timers",
|
||||||
"name": "Timers",
|
"name": "Timers",
|
||||||
"version": "1.5.1",
|
"version": "1.7.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||||
"provides": [
|
"provides": [
|
||||||
@@ -161,6 +161,12 @@
|
|||||||
"default": false,
|
"default": false,
|
||||||
"group": "Show"
|
"group": "Show"
|
||||||
},
|
},
|
||||||
|
"showLoops": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Looping videos",
|
||||||
|
"default": false,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
"showHeadings": {
|
"showHeadings": {
|
||||||
"type": "switch",
|
"type": "switch",
|
||||||
"label": "Source headings",
|
"label": "Source headings",
|
||||||
|
|||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
# Build this module's native helper for every macOS architecture it ships —
|
||||||
|
# ../bin/ltc-capture-macos-arm64 and ../bin/ltc-capture-macos-x86_64 —
|
||||||
|
# from ltc-capture.swift beside this script. Run it on any Mac with the
|
||||||
|
# Xcode command line tools; both architectures cross-compile from either.
|
||||||
|
# The binaries are committed: a machine running ProdDash never builds
|
||||||
|
# anything (docs/MODULE-GUIDE.md, "Native helpers").
|
||||||
|
#
|
||||||
|
# Signed with a Developer ID Application certificate when one is installed
|
||||||
|
# (override with CODESIGN_IDENTITY) — hardened runtime plus the audio-input
|
||||||
|
# entitlement, which a helper that reads a microphone needs under the
|
||||||
|
# hardened runtime — and ad-hoc otherwise, which is fine on your own Macs.
|
||||||
|
#
|
||||||
|
# Windows is not built here: there is no Windows implementation of this
|
||||||
|
# tool yet, so on Windows the module reports timecode off.
|
||||||
|
set -e -u
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
NAME="ltc-capture"
|
||||||
|
DEPLOYMENT_TARGET="13.0"
|
||||||
|
|
||||||
|
command -v swiftc >/dev/null || {
|
||||||
|
echo "build.sh: swiftc not found — install the Xcode command line tools: xcode-select --install" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
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 — fine on your own Macs)"
|
||||||
|
SIGN=(--force --sign - --options runtime)
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p ../bin
|
||||||
|
for ARCH in arm64 x86_64; do
|
||||||
|
OUT="../bin/$NAME-macos-$ARCH"
|
||||||
|
echo " compiling $OUT"
|
||||||
|
swiftc -O -whole-module-optimization -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$OUT" "$NAME.swift"
|
||||||
|
codesign "${SIGN[@]}" --entitlements helper.entitlements "$OUT"
|
||||||
|
codesign --verify --strict "$OUT"
|
||||||
|
echo " built $OUT ($(du -h "$OUT" | awk '{print $1}'))"
|
||||||
|
done
|
||||||
@@ -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 helper reads an audio input; under the hardened runtime that
|
||||||
|
needs saying. The permission itself comes from the launcher. -->
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
+5
-5
@@ -4,12 +4,12 @@
|
|||||||
// The module decodes LTC in-process (ltc.js) and spawns this to move
|
// The module decodes LTC in-process (ltc.js) and spawns this to move
|
||||||
// samples from an input device to a pipe. Production machines rarely have
|
// samples from an input device to a pipe. Production machines rarely have
|
||||||
// ffmpeg/sox, and shipping a third-party binary for that one job is
|
// ffmpeg/sox, and shipping a third-party binary for that one job is
|
||||||
// overkill — this is the whole job, natively. The module compiles it on
|
// overkill — this is the whole job, natively. It ships prebuilt in the
|
||||||
// first use when swiftc is available; otherwise build it once on any
|
// module's bin/ (one binary per platform, committed — a booth never
|
||||||
// same-architecture Mac and copy the binary next to this source:
|
// builds anything). To rebuild after a change here, on any Mac with the
|
||||||
|
// Xcode command line tools:
|
||||||
//
|
//
|
||||||
// swiftc -O -o modules/propresenter-timers/ltc-capture \
|
// modules/propresenter-timers/native/build.sh (both architectures, ~10 s)
|
||||||
// modules/propresenter-timers/ltc-capture.swift (once, ~5 s)
|
|
||||||
//
|
//
|
||||||
// ltc-capture --list
|
// ltc-capture --list
|
||||||
// every input-capable device: name, input channels, sample rate
|
// every input-capable device: name, input channels, sample rate
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
const http = require('http');
|
const http = require('http');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const { createLtcListener, ensureCaptureBinary } = require('./ltc-listener');
|
const { createLtcListener, captureTool } = require('./ltc-listener');
|
||||||
const { createTimerPhase } = require('./phase');
|
const { createTimerPhase } = require('./phase');
|
||||||
const manifest = require('./module.json');
|
const manifest = require('./module.json');
|
||||||
|
|
||||||
@@ -255,6 +255,21 @@ function createProPresenterSource({ config, log }) {
|
|||||||
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
|
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
|
||||||
const phases = new Map();
|
const phases = new Map();
|
||||||
|
|
||||||
|
/* The video countdown — time left on what the presentation layer is
|
||||||
|
playing, from the transport routes (verified live on ProPresenter 21.4):
|
||||||
|
GET /v1/transport/presentation/current → { is_playing, uuid, name,
|
||||||
|
artist, audio_only, duration } (no looping flag exists — inferred)
|
||||||
|
GET /v1/transport/presentation/time → bare seconds, sub-second
|
||||||
|
float — better than /v1/timer/video_countdown's whole-second string,
|
||||||
|
so no quantization phase lock is needed, only an anchor hold. */
|
||||||
|
let media = null; // { uuid, name, duration, playing } from the slow lane
|
||||||
|
let mediaAt = 0;
|
||||||
|
let transportOk = null; // null until the first answer; false = no transport route (older ProPresenter)
|
||||||
|
let videoPos = null; // playback position in seconds (float)
|
||||||
|
let videoAsOf = 0; // server ms when videoPos was measured
|
||||||
|
let videoAnchor = null; // published { remainingMs, asOf }, held while it predicts the measurements
|
||||||
|
let loopingUuid = ''; // media seen wrapping — a looping background
|
||||||
|
|
||||||
function url(target) {
|
function url(target) {
|
||||||
const built = new URL(`http://${host}:${port}${target}`);
|
const built = new URL(`http://${host}:${port}${target}`);
|
||||||
if (password) built.searchParams.set('password', password);
|
if (password) built.searchParams.set('password', password);
|
||||||
@@ -390,6 +405,121 @@ function createProPresenterSource({ config, log }) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follow the presentation transport layer. Failures here only drop the
|
||||||
|
* video card, never the whole source — the timers must survive a
|
||||||
|
* ProPresenter build with transport quirks.
|
||||||
|
*/
|
||||||
|
async function pollVideo() {
|
||||||
|
if (tick % SLOW_EVERY === 1) {
|
||||||
|
try {
|
||||||
|
const raw = await requestJson('/v1/transport/presentation/current');
|
||||||
|
const uuid = String(raw?.uuid || '');
|
||||||
|
if (!media || media.uuid !== uuid) {
|
||||||
|
if (media && media.uuid !== uuid) loopingUuid = '';
|
||||||
|
videoPos = null;
|
||||||
|
videoAnchor = null;
|
||||||
|
}
|
||||||
|
media = uuid ? {
|
||||||
|
uuid,
|
||||||
|
name: String(raw.name || ''),
|
||||||
|
duration: Number(raw.duration) > 0 ? Number(raw.duration) : 0,
|
||||||
|
playing: Boolean(raw.is_playing),
|
||||||
|
} : null;
|
||||||
|
mediaAt = Date.now();
|
||||||
|
transportOk = true;
|
||||||
|
} catch (err) {
|
||||||
|
media = null; // the slow lane retries in ~2 s
|
||||||
|
if (err && err.status === 404) transportOk = false; // no transport route on this build
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!media) {
|
||||||
|
videoPos = null;
|
||||||
|
videoAnchor = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pos = Number(await requestJson('/v1/transport/presentation/time'));
|
||||||
|
if (Number.isFinite(pos) && pos >= 0) {
|
||||||
|
// Same media, position jumped back by more than a stutter: it
|
||||||
|
// wrapped — a looping background. Sticks until the media changes.
|
||||||
|
// (A video manually re-triggered from the start looks the same and
|
||||||
|
// is mislabeled for that pass; loops re-wrap, replays don't recur.)
|
||||||
|
if (videoPos !== null && media.playing && videoPos - pos > 1.5) loopingUuid = media.uuid;
|
||||||
|
videoPos = pos;
|
||||||
|
videoAsOf = Date.now();
|
||||||
|
} else {
|
||||||
|
videoPos = null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
videoPos = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The video countdown as one synthetic timer card (id 'video'). The card
|
||||||
|
persists as an idle "—" between videos — a wall tile must not appear
|
||||||
|
and vanish with the media — and is only absent entirely on a
|
||||||
|
ProPresenter without transport routes. */
|
||||||
|
function videoTimer() {
|
||||||
|
if (transportOk !== true) return null;
|
||||||
|
if (!media) {
|
||||||
|
return {
|
||||||
|
id: 'video',
|
||||||
|
label: 'Video',
|
||||||
|
kind: 'countdown',
|
||||||
|
state: 'idle',
|
||||||
|
asOf: mediaAt || updatedAt,
|
||||||
|
startedAt: null,
|
||||||
|
elapsedMs: null,
|
||||||
|
remainingMs: null,
|
||||||
|
targetMs: null,
|
||||||
|
looping: false,
|
||||||
|
status: null,
|
||||||
|
detail: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const playing = media.playing;
|
||||||
|
const dur = media.duration > 0 ? media.duration * 1000 : null;
|
||||||
|
let remainingMs = null;
|
||||||
|
let asOf = videoAsOf || mediaAt;
|
||||||
|
if (videoPos !== null && dur !== null) {
|
||||||
|
remainingMs = Math.max(0, dur - videoPos * 1000);
|
||||||
|
if (playing) {
|
||||||
|
// Hold the published anchor while it still predicts the measured
|
||||||
|
// position: the tiles extrapolate locally, and a fresh anchor per
|
||||||
|
// poll would broadcast a frame every 500 ms for nothing.
|
||||||
|
const p = videoAnchor;
|
||||||
|
const predicted = p ? p.remainingMs - (videoAsOf - p.asOf) : NaN;
|
||||||
|
if (p && Math.abs(predicted - remainingMs) < 120) {
|
||||||
|
remainingMs = p.remainingMs;
|
||||||
|
asOf = p.asOf;
|
||||||
|
} else {
|
||||||
|
videoAnchor = { remainingMs, asOf };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
videoAnchor = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
videoAnchor = null;
|
||||||
|
}
|
||||||
|
const looping = media.uuid === loopingUuid;
|
||||||
|
return {
|
||||||
|
id: 'video',
|
||||||
|
label: 'Video',
|
||||||
|
kind: 'countdown',
|
||||||
|
state: playing ? 'running' : videoPos > 0 ? 'paused' : 'idle',
|
||||||
|
asOf,
|
||||||
|
startedAt: null,
|
||||||
|
elapsedMs: remainingMs !== null && dur !== null ? dur - remainingMs : null,
|
||||||
|
remainingMs,
|
||||||
|
targetMs: dur,
|
||||||
|
looping,
|
||||||
|
status: looping ? { text: playing ? 'Looping' : 'Paused', tone: 'muted' } : null,
|
||||||
|
// The media name, without the file extension nobody reads on a wall.
|
||||||
|
detail: media.name.replace(/\.(mp4|mov|m4v|mxf|avi|mkv|mp3|wav|aiff?)$/i, ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function status() {
|
function status() {
|
||||||
if (!configured) return 'error';
|
if (!configured) return 'error';
|
||||||
if (reachable) return 'ok';
|
if (reachable) return 'ok';
|
||||||
@@ -429,6 +559,9 @@ function createProPresenterSource({ config, log }) {
|
|||||||
const currentTimes = await requestJson('/v1/timers/current');
|
const currentTimes = await requestJson('/v1/timers/current');
|
||||||
updatedAt = Date.now();
|
updatedAt = Date.now();
|
||||||
timers = mergeTimers(currentTimes, updatedAt);
|
timers = mergeTimers(currentTimes, updatedAt);
|
||||||
|
await pollVideo();
|
||||||
|
const video = videoTimer();
|
||||||
|
if (video) timers.push(video);
|
||||||
reachable = true;
|
reachable = true;
|
||||||
lastError = '';
|
lastError = '';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -527,11 +660,8 @@ function createLtcSource({ config, log, moduleDir }) {
|
|||||||
kind: 'ltc',
|
kind: 'ltc',
|
||||||
start(e) {
|
start(e) {
|
||||||
emit = e;
|
emit = e;
|
||||||
// Kick the capture-tool build right away: a catalog install ships
|
// The capture tool ships prebuilt in the module; the listener picks
|
||||||
// source only and a catalog update wipes any previously built binary,
|
// it for this platform when it starts and says so when there is none.
|
||||||
// so coming up after an install/update must rebuild it without
|
|
||||||
// waiting for a device to be picked or a listener to need it.
|
|
||||||
ensureCaptureBinary({ moduleDir, log });
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
word = {
|
word = {
|
||||||
error: 'Not configured — see Admin',
|
error: 'Not configured — see Admin',
|
||||||
@@ -970,6 +1100,8 @@ const GEAR = {
|
|||||||
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
|
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
|
||||||
/** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
|
/** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
|
||||||
timer: gearOf([...TIMER_ELEMENTS, ...COLOURS], OPEN_COLOURS),
|
timer: gearOf([...TIMER_ELEMENTS, ...COLOURS], OPEN_COLOURS),
|
||||||
|
/** The video countdown: a timer card, plus whether looping media counts. */
|
||||||
|
video: gearOf([...TIMER_ELEMENTS, 'showLoops', ...COLOURS], OPEN_COLOURS),
|
||||||
/** The timecode: running, no signal (warning) and stopped (idle) — it never overruns, and frames are frames. */
|
/** The timecode: running, no signal (warning) and stopped (idle) — it never overruns, and frames are frames. */
|
||||||
ltc: gearOf(['showNames', 'showStatus', 'runningColor', 'warningColor', 'idleColor'], OPEN_COLOURS),
|
ltc: gearOf(['showNames', 'showStatus', 'runningColor', 'warningColor', 'idleColor'], OPEN_COLOURS),
|
||||||
/** The time of day: plain digits, so only how they read. */
|
/** The time of day: plain digits, so only how they read. */
|
||||||
@@ -983,6 +1115,7 @@ function gearFor(source, timer) {
|
|||||||
if (source.kind === 'ltc') return GEAR.ltc;
|
if (source.kind === 'ltc') return GEAR.ltc;
|
||||||
if (source.kind === 'clock') return GEAR.clock;
|
if (source.kind === 'clock') return GEAR.clock;
|
||||||
if (timer.kind === 'clock') return GEAR.providerClock;
|
if (timer.kind === 'clock') return GEAR.providerClock;
|
||||||
|
if (source.id === 'propresenter' && timer.id === 'video') return GEAR.video;
|
||||||
return GEAR.timer;
|
return GEAR.timer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1191,6 +1324,7 @@ module.exports = {
|
|||||||
for (const t of src.timers) {
|
for (const t of src.timers) {
|
||||||
const description = src.kind === 'ltc' ? 'The incoming LTC timecode on its own'
|
const description = src.kind === 'ltc' ? 'The incoming LTC timecode on its own'
|
||||||
: src.kind === 'clock' ? 'The time of day on its own'
|
: src.kind === 'clock' ? 'The time of day on its own'
|
||||||
|
: src.id === 'propresenter' && t.id === 'video' ? 'Time left on the video ProPresenter is playing'
|
||||||
: `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} on its own`;
|
: `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} on its own`;
|
||||||
// Layouts from before 1.3.0 hold 'timer:<uuid>' / 'ltc' — the same tiles.
|
// Layouts from before 1.3.0 hold 'timer:<uuid>' / 'ltc' — the same tiles.
|
||||||
const aliases = src.id === 'propresenter' ? [`timer:${t.id}`] : src.kind === 'ltc' ? ['ltc'] : [];
|
const aliases = src.id === 'propresenter' ? [`timer:${t.id}`] : src.kind === 'ltc' ? ['ltc'] : [];
|
||||||
@@ -1222,23 +1356,21 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// The admin device picker asks this machine what it can hear. The
|
// The admin device picker asks this machine what it can hear. The
|
||||||
// options shape matches the admin select's optionsRoute contract.
|
// options shape matches the admin select's optionsRoute contract. The
|
||||||
// Opening the picker also kicks the capture-tool build when a catalog
|
// capture tool ships prebuilt in the module (bin/); with none for this
|
||||||
// install/update left only the source — without this, no tool meant
|
// platform the list is empty and the error says so.
|
||||||
// no device list, and no device meant nothing would ever build it.
|
|
||||||
'GET /ltc/devices': (req, res) => {
|
'GET /ltc/devices': (req, res) => {
|
||||||
const tool = ensureCaptureBinary({ moduleDir: __dirname, log });
|
const tool = captureTool(__dirname);
|
||||||
if (!tool.ready) {
|
if (!tool.ready) {
|
||||||
sendJson(res, 200, { options: [], error: tool.detail || tool.error });
|
sendJson(res, 200, { options: [], error: tool.detail });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const bin = path.join(__dirname, 'ltc-capture');
|
const child = spawn(tool.bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
|
||||||
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
|
|
||||||
let err = '';
|
let err = '';
|
||||||
child.stderr.setEncoding('utf8');
|
child.stderr.setEncoding('utf8');
|
||||||
child.stderr.on('data', (t) => { err += t; });
|
child.stderr.on('data', (t) => { err += t; });
|
||||||
child.on('error', () => {
|
child.on('error', (e) => {
|
||||||
sendJson(res, 200, { options: [], error: 'LTC capture tool missing — see modules/propresenter-timers/ltc-capture.swift' });
|
sendJson(res, 200, { options: [], error: `LTC capture tool could not be run: ${e.message}` });
|
||||||
});
|
});
|
||||||
child.on('exit', () => {
|
child.on('exit', () => {
|
||||||
if (res.writableEnded) return;
|
if (res.writableEnded) return;
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "spl-meter",
|
"id": "spl-meter",
|
||||||
"name": "SPL Meter",
|
"name": "SPL Meter",
|
||||||
"version": "0.4.1",
|
"version": "0.5.0",
|
||||||
"proddash": ">=1.5.0",
|
"proddash": ">=1.5.0",
|
||||||
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
# Build this module's native helper for every macOS architecture it ships —
|
||||||
|
# ../bin/spl-capture-macos-arm64 and ../bin/spl-capture-macos-x86_64 —
|
||||||
|
# from spl-capture.swift beside this script. Run it on any Mac with the
|
||||||
|
# Xcode command line tools; both architectures cross-compile from either.
|
||||||
|
# The binaries are committed: a machine running ProdDash never builds
|
||||||
|
# anything (docs/MODULE-GUIDE.md, "Native helpers").
|
||||||
|
#
|
||||||
|
# Signed with a Developer ID Application certificate when one is installed
|
||||||
|
# (override with CODESIGN_IDENTITY) — hardened runtime plus the audio-input
|
||||||
|
# entitlement, which a helper that reads a microphone needs under the
|
||||||
|
# hardened runtime — and ad-hoc otherwise, which is fine on your own Macs.
|
||||||
|
#
|
||||||
|
# Windows is not built here: there is no Windows implementation of this
|
||||||
|
# tool yet, so on Windows the module reports measurement off.
|
||||||
|
set -e -u
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
NAME="spl-capture"
|
||||||
|
DEPLOYMENT_TARGET="13.0"
|
||||||
|
|
||||||
|
command -v swiftc >/dev/null || {
|
||||||
|
echo "build.sh: swiftc not found — install the Xcode command line tools: xcode-select --install" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
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 — fine on your own Macs)"
|
||||||
|
SIGN=(--force --sign - --options runtime)
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p ../bin
|
||||||
|
for ARCH in arm64 x86_64; do
|
||||||
|
OUT="../bin/$NAME-macos-$ARCH"
|
||||||
|
echo " compiling $OUT"
|
||||||
|
swiftc -O -whole-module-optimization -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$OUT" "$NAME.swift"
|
||||||
|
codesign "${SIGN[@]}" --entitlements helper.entitlements "$OUT"
|
||||||
|
codesign --verify --strict "$OUT"
|
||||||
|
echo " built $OUT ($(du -h "$OUT" | awk '{print $1}'))"
|
||||||
|
done
|
||||||
@@ -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 helper reads an audio input; under the hardened runtime that
|
||||||
|
needs saying. The permission itself comes from the launcher. -->
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -8,10 +8,11 @@
|
|||||||
// measurement is done here and only the answers cross the pipe.
|
// measurement is done here and only the answers cross the pipe.
|
||||||
//
|
//
|
||||||
// Everything below uses Accelerate, which ships with macOS. No third-party
|
// Everything below uses Accelerate, which ships with macOS. No third-party
|
||||||
// code, no package manager, ~2 s to build:
|
// code, no package manager. It ships prebuilt in the module's bin/ (one
|
||||||
|
// binary per platform, committed — a booth never builds anything). To
|
||||||
|
// rebuild after a change here, on any Mac with the Xcode command line tools:
|
||||||
//
|
//
|
||||||
// swiftc -O -o modules/spl-meter/spl-capture \
|
// modules/spl-meter/native/build.sh (both architectures)
|
||||||
// modules/spl-meter/spl-capture.swift
|
|
||||||
//
|
//
|
||||||
// spl-capture --list
|
// spl-capture --list
|
||||||
// every input-capable device: name, input channels, sample rate
|
// every input-capable device: name, input channels, sample rate
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { createSplListener } = require('./spl-listener');
|
const { createSplListener, captureTool } = require('./spl-listener');
|
||||||
|
|
||||||
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
|
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
|
||||||
const CALIBRATOR_DB = 94;
|
const CALIBRATOR_DB = 94;
|
||||||
@@ -114,11 +114,16 @@ module.exports = {
|
|||||||
error: '',
|
error: '',
|
||||||
streams: new Set(),
|
streams: new Set(),
|
||||||
silentSince: null,
|
silentSince: null,
|
||||||
|
toolMissing: '',
|
||||||
listener: null,
|
listener: null,
|
||||||
lastSent: '',
|
lastSent: '',
|
||||||
pinger: null,
|
pinger: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The capture tool ships prebuilt in the module (bin/); with none for
|
||||||
|
// this platform every reading is off and the health line says so.
|
||||||
|
state.toolMissing = captureTool(moduleDir).missing;
|
||||||
|
|
||||||
if (!config.enabled) {
|
if (!config.enabled) {
|
||||||
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
|
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
|
||||||
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||||
@@ -177,6 +182,7 @@ module.exports = {
|
|||||||
return {
|
return {
|
||||||
stop() { teardown(); },
|
stop() { teardown(); },
|
||||||
health() {
|
health() {
|
||||||
|
if (state.toolMissing) return { status: 'error', message: state.toolMissing };
|
||||||
if (state.error) return { status: 'error', message: state.error };
|
if (state.error) return { status: 'error', message: state.error };
|
||||||
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
||||||
const quiet = silentMs();
|
const quiet = silentMs();
|
||||||
@@ -300,6 +306,12 @@ module.exports = {
|
|||||||
/** Device list for the admin page. Runs the capture tool's --list,
|
/** Device list for the admin page. Runs the capture tool's --list,
|
||||||
which is the only thing that knows what CoreAudio can see. */
|
which is the only thing that knows what CoreAudio can see. */
|
||||||
'GET /devices': async (req, res) => {
|
'GET /devices': async (req, res) => {
|
||||||
|
const tool = captureTool(__dirname);
|
||||||
|
if (tool.missing) {
|
||||||
|
log(`device list unavailable — ${tool.missing}`);
|
||||||
|
json(res, 200, { options: [], error: tool.missing });
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const devices = await listDevices();
|
const devices = await listDevices();
|
||||||
json(res, 200, {
|
json(res, 200, {
|
||||||
@@ -429,7 +441,7 @@ function teardown() {
|
|||||||
function listDevices() {
|
function listDevices() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const { execFile } = require('child_process');
|
const { execFile } = require('child_process');
|
||||||
execFile(path.join(__dirname, 'spl-capture'), ['--list'], { timeout: 10000 },
|
execFile(captureTool(__dirname).bin, ['--list'], { timeout: 10000 },
|
||||||
(err, stdout) => {
|
(err, stdout) => {
|
||||||
if (err && !stdout) { reject(err); return; }
|
if (err && !stdout) { reject(err); return; }
|
||||||
try { resolve(JSON.parse(stdout).devices || []); }
|
try { resolve(JSON.parse(stdout).devices || []); }
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
/**
|
/**
|
||||||
* Sound level listener — owns the native capture child.
|
* Sound level listener — owns the native capture child.
|
||||||
*
|
*
|
||||||
* Spawns spl-capture (compiled from spl-capture.swift beside it) for the
|
* Spawns spl-capture (bin/spl-capture-macos-<arch>, shipped prebuilt;
|
||||||
|
* source in native/) for the
|
||||||
* admin-configured device and channels, reads its JSON lines, and hands the
|
* admin-configured device and channels, reads its JSON lines, and hands the
|
||||||
* latest frame upward. All measurement happens in the child; this file is
|
* latest frame upward. All measurement happens in the child; this file is
|
||||||
* plumbing and supervision, nothing more.
|
* plumbing and supervision, nothing more.
|
||||||
@@ -13,11 +14,11 @@
|
|||||||
* { frame: {...}, meta: {...} } measuring
|
* { frame: {...}, meta: {...} } measuring
|
||||||
* { error: '<what is wrong and how to fix it>' } not measuring
|
* { error: '<what is wrong and how to fix it>' } not measuring
|
||||||
*
|
*
|
||||||
* The capture binary is per-machine and gitignored, exactly like the Timers
|
* The capture tool ships prebuilt inside the module, one binary per
|
||||||
* module's ltc-capture. It normally arrives already built — compiled beside
|
* platform under bin/ (module guide, "Native helpers"), exactly like the
|
||||||
* the app, or copied from any Mac of the same architecture. When it is
|
* Timers module's ltc-capture: nothing is ever built on the machine running
|
||||||
* missing this will build it, but in the background and never on the event
|
* ProdDash. captureTool() picks the binary for this platform; when there is
|
||||||
* loop: see ensureBinary for what that cost the first time it was wrong.
|
* none, measurement reports itself off and the rest of the module carries on.
|
||||||
*
|
*
|
||||||
* The same macOS gotcha applies as for LTC, and is worth repeating because
|
* The same macOS gotcha applies as for LTC, and is worth repeating because
|
||||||
* it costs an afternoon every time: a ProdDash launched over SSH or from a
|
* it costs an afternoon every time: a ProdDash launched over SSH or from a
|
||||||
@@ -30,6 +31,33 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
/* ── the capture tool: prebuilt, one binary per platform ───────────────── */
|
||||||
|
|
||||||
|
/** What the module ships in bin/, by platform and architecture. */
|
||||||
|
const HELPERS = {
|
||||||
|
darwin: { arm64: 'spl-capture-macos-arm64', x64: 'spl-capture-macos-x86_64' },
|
||||||
|
win32: { x64: 'spl-capture-win-x64.exe' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The capture tool for this machine. It is never built here and never
|
||||||
|
* looked for anywhere else: the module ships it, or measurement is off on
|
||||||
|
* this platform and says so. `bin` is the usable binary; otherwise
|
||||||
|
* `missing` is what to tell the admin page and the tiles.
|
||||||
|
*
|
||||||
|
* @returns {{bin:string, missing:string}}
|
||||||
|
*/
|
||||||
|
function captureTool(moduleDir, platform = process.platform, arch = process.arch) {
|
||||||
|
const name = HELPERS[platform]?.[arch] || '';
|
||||||
|
const bin = name ? path.join(moduleDir, 'bin', name) : '';
|
||||||
|
if (bin && fs.existsSync(bin)) return { bin, missing: '' };
|
||||||
|
const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`;
|
||||||
|
return {
|
||||||
|
bin: '',
|
||||||
|
missing: `The module ships no sound level capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — measurement is off on this machine`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const RESTART_MS = 5000;
|
const RESTART_MS = 5000;
|
||||||
/** An immediate exit is config-shaped (bad device, channel out of range) —
|
/** An immediate exit is config-shaped (bad device, channel out of range) —
|
||||||
retrying every 5 s would only fill the log with the same sentence. */
|
retrying every 5 s would only fill the log with the same sentence. */
|
||||||
@@ -45,7 +73,6 @@ const STALL_MS = 3000;
|
|||||||
*/
|
*/
|
||||||
function createSplListener(opts) {
|
function createSplListener(opts) {
|
||||||
const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts;
|
const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts;
|
||||||
const bin = path.join(moduleDir, 'spl-capture');
|
|
||||||
|
|
||||||
let child = null;
|
let child = null;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
@@ -53,66 +80,8 @@ function createSplListener(opts) {
|
|||||||
let stallTimer = null;
|
let stallTimer = null;
|
||||||
let startedAt = 0;
|
let startedAt = 0;
|
||||||
let meta = null;
|
let meta = null;
|
||||||
let building = false;
|
|
||||||
let buildFailed = false;
|
|
||||||
let lastFrameWall = 0;
|
let lastFrameWall = 0;
|
||||||
|
|
||||||
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
|
|
||||||
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
|
|
||||||
+ 'and copy it next to spl-capture.swift';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the capture tool if it is missing, WITHOUT blocking.
|
|
||||||
*
|
|
||||||
* This used to be a spawnSync, copied from the LTC listener where the
|
|
||||||
* source is a third the size and builds in a few seconds. Here it took
|
|
||||||
* nearly two minutes on the production Mac and froze the entire ProdDash
|
|
||||||
* server for every second of it — one module's optional helper took the
|
|
||||||
* whole dashboard down, then did it again on every retry. Nothing that
|
|
||||||
* runs while a service might be on is allowed to hold the event loop.
|
|
||||||
*
|
|
||||||
* The build is a fallback for a module installed from the catalog on a
|
|
||||||
* machine with a Swift toolchain. The normal path is a binary built
|
|
||||||
* alongside the app, or copied from any same-architecture Mac.
|
|
||||||
*
|
|
||||||
* @returns {string} '' when the binary is ready, otherwise what to say.
|
|
||||||
*/
|
|
||||||
function ensureBinary() {
|
|
||||||
if (fs.existsSync(bin)) return '';
|
|
||||||
const src = bin + '.swift';
|
|
||||||
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
|
|
||||||
if (building) return 'Building the capture tool — this takes a minute or two';
|
|
||||||
if (buildFailed) return BUILD_HELP;
|
|
||||||
|
|
||||||
building = true;
|
|
||||||
log('spl-capture binary missing — building it in the background');
|
|
||||||
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
||||||
let buildErr = '';
|
|
||||||
build.stderr.setEncoding('utf8');
|
|
||||||
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
|
||||||
const giveUp = setTimeout(() => {
|
|
||||||
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
|
||||||
}, 300000);
|
|
||||||
build.on('error', (err) => {
|
|
||||||
clearTimeout(giveUp);
|
|
||||||
building = false; buildFailed = true;
|
|
||||||
log(`swiftc could not be run: ${err.message}`);
|
|
||||||
});
|
|
||||||
build.on('exit', (code) => {
|
|
||||||
clearTimeout(giveUp);
|
|
||||||
building = false;
|
|
||||||
if (code === 0 && fs.existsSync(bin)) {
|
|
||||||
log('spl-capture built');
|
|
||||||
buildFailed = false;
|
|
||||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
buildFailed = true;
|
|
||||||
log(`spl-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
|
||||||
});
|
|
||||||
return 'Building the capture tool — this takes a minute or two';
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleRestart(ms) {
|
function scheduleRestart(ms) {
|
||||||
if (stopped || restartTimer) return;
|
if (stopped || restartTimer) return;
|
||||||
restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms);
|
restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms);
|
||||||
@@ -132,12 +101,12 @@ function createSplListener(opts) {
|
|||||||
function start() {
|
function start() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
|
|
||||||
const missing = ensureBinary();
|
const tool = captureTool(moduleDir);
|
||||||
if (missing) {
|
if (tool.missing) {
|
||||||
onUpdate({ error: missing });
|
// No binary for this platform: say so and look again later — a module
|
||||||
// A build in flight calls start() itself when it lands; anything else
|
// update remounts us anyway, this is only belt and braces.
|
||||||
// is config-shaped and should back off hard rather than spin.
|
onUpdate({ error: tool.missing });
|
||||||
if (!building) scheduleRestart(BAD_CONFIG_RETRY_MS);
|
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +122,7 @@ function createSplListener(opts) {
|
|||||||
let stderrTail = '';
|
let stderrTail = '';
|
||||||
let buf = '';
|
let buf = '';
|
||||||
|
|
||||||
child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
child = spawn(tool.bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
|
|
||||||
child.stderr.setEncoding('utf8');
|
child.stderr.setEncoding('utf8');
|
||||||
child.stderr.on('data', (text) => {
|
child.stderr.on('data', (text) => {
|
||||||
@@ -236,4 +205,4 @@ function createSplListener(opts) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createSplListener };
|
module.exports = { createSplListener, captureTool };
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.9.2",
|
"version": "1.10.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
|||||||
@@ -443,6 +443,7 @@ a.btn { text-decoration: none; }
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
.catalog-main { flex: 1; min-width: 0; }
|
.catalog-main { flex: 1; min-width: 0; }
|
||||||
|
.catalog-pick { margin: 5px 10px 0 0; flex: none; }
|
||||||
.catalog-title { display: flex; align-items: center; gap: 8px; }
|
.catalog-title { display: flex; align-items: center; gap: 8px; }
|
||||||
.catalog-row .module-desc { margin: 4px 0 2px; }
|
.catalog-row .module-desc { margin: 4px 0 2px; }
|
||||||
.catalog-row .module-health { margin-top: 2px; }
|
.catalog-row .module-health { margin-top: 2px; }
|
||||||
|
|||||||
@@ -962,6 +962,13 @@ async function loadState() {
|
|||||||
for (const mod of state.modules || []) {
|
for (const mod of state.modules || []) {
|
||||||
modulesList.appendChild(buildModuleCard(mod));
|
modulesList.appendChild(buildModuleCard(mod));
|
||||||
}
|
}
|
||||||
|
if (!(state.modules || []).length) {
|
||||||
|
// A packaged ProdDash starts like this: the catalog below is the way in.
|
||||||
|
const none = document.createElement('div');
|
||||||
|
none.className = 'layouts-empty';
|
||||||
|
none.textContent = 'Nothing installed yet — pick from Available modules below.';
|
||||||
|
modulesList.appendChild(none);
|
||||||
|
}
|
||||||
renderPin(state);
|
renderPin(state);
|
||||||
loadTheme();
|
loadTheme();
|
||||||
loadLayouts();
|
loadLayouts();
|
||||||
@@ -1163,6 +1170,7 @@ function renderAvailable(cat) {
|
|||||||
els.updateSlot.appendChild(btn);
|
els.updateSlot.appendChild(btn);
|
||||||
}
|
}
|
||||||
const available = cat.available || [];
|
const available = cat.available || [];
|
||||||
|
installSelectedBtn.hidden = available.filter((a) => a.compatible).length < 2;
|
||||||
if (!available.length) {
|
if (!available.length) {
|
||||||
const none = document.createElement('div');
|
const none = document.createElement('div');
|
||||||
none.className = 'layouts-empty';
|
none.className = 'layouts-empty';
|
||||||
@@ -1205,11 +1213,46 @@ function renderAvailable(cat) {
|
|||||||
btn.disabled = !item.compatible;
|
btn.disabled = !item.compatible;
|
||||||
btn.title = item.compatible ? '' : item.reason;
|
btn.title = item.compatible ? '' : item.reason;
|
||||||
btn.addEventListener('click', () => installFromCatalog(item, btn));
|
btn.addEventListener('click', () => installFromCatalog(item, btn));
|
||||||
|
if (item.compatible) {
|
||||||
|
const pick = document.createElement('input');
|
||||||
|
pick.type = 'checkbox';
|
||||||
|
pick.className = 'catalog-pick';
|
||||||
|
pick.dataset.id = item.id;
|
||||||
|
pick.dataset.name = item.name;
|
||||||
|
pick.title = 'Tick several, then Install selected';
|
||||||
|
row.append(pick);
|
||||||
|
}
|
||||||
row.append(main, btn);
|
row.append(main, btn);
|
||||||
availableList.appendChild(row);
|
availableList.appendChild(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const installSelectedBtn = document.getElementById('install-selected');
|
||||||
|
|
||||||
|
/** The ticked modules, in one request; the answer says which ones landed. */
|
||||||
|
async function installSelected() {
|
||||||
|
const picks = [...availableList.querySelectorAll('.catalog-pick:checked')];
|
||||||
|
if (!picks.length) return toast('Tick the modules to install first', true);
|
||||||
|
installSelectedBtn.disabled = true;
|
||||||
|
installSelectedBtn.textContent = `Installing ${picks.length}…`;
|
||||||
|
try {
|
||||||
|
const r = await api('/api/admin/modules/install', { method: 'POST', body: JSON.stringify({ ids: picks.map((p) => p.dataset.id) }) });
|
||||||
|
const done = (r.results || []).filter((x) => x.ok).length;
|
||||||
|
const failed = (r.results || []).filter((x) => !x.ok);
|
||||||
|
toast(failed.length
|
||||||
|
? `${done} installed; ${failed.map((f) => `${f.id}: ${f.error}`).join(' · ')}`
|
||||||
|
: `${done} module${done === 1 ? '' : 's'} installed`, failed.length > 0);
|
||||||
|
await loadState();
|
||||||
|
} catch (e) {
|
||||||
|
if (e.authRequired) showLogin();
|
||||||
|
else toast(e.message, true);
|
||||||
|
} finally {
|
||||||
|
installSelectedBtn.disabled = false;
|
||||||
|
installSelectedBtn.textContent = 'Install selected';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
installSelectedBtn.addEventListener('click', installSelected);
|
||||||
|
|
||||||
async function installFromCatalog(item, btn) {
|
async function installFromCatalog(item, btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
const label = btn.textContent;
|
const label = btn.textContent;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
<section id="available-section" hidden>
|
<section id="available-section" hidden>
|
||||||
<h1 class="admin-h1">Available modules</h1>
|
<h1 class="admin-h1">Available modules</h1>
|
||||||
<div class="catalog-toolbar">
|
<div class="catalog-toolbar">
|
||||||
|
<button class="btn small primary" id="install-selected" hidden>Install selected</button>
|
||||||
<button class="btn small" id="catalog-refresh">Check again</button>
|
<button class="btn small" id="catalog-refresh">Check again</button>
|
||||||
<span class="admin-status" id="catalog-status"></span>
|
<span class="admin-status" id="catalog-status"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+51
-10
@@ -510,6 +510,7 @@ function reconcileOfferedTiles() {
|
|||||||
}
|
}
|
||||||
if (changed) rebuildDividers();
|
if (changed) rebuildDividers();
|
||||||
scheduleOfferedRecheck();
|
scheduleOfferedRecheck();
|
||||||
|
refreshEmptyState(); // the registry may have gone from nothing to something
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A module's entry list can change without anyone saving admin config: PCO
|
/* A module's entry list can change without anyone saving admin config: PCO
|
||||||
@@ -639,8 +640,10 @@ function buildTile(tile) {
|
|||||||
el.addEventListener('pointermove', (ev) => {
|
el.addEventListener('pointermove', (ev) => {
|
||||||
if (!headIsOff(tile)) return;
|
if (!headIsOff(tile)) return;
|
||||||
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
|
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
|
||||||
// — neither is a moment to take the bar away.
|
// — neither is a moment to take the bar away. Nor is an open header
|
||||||
|
// menu, which hangs below the bar and is about to be pointed at.
|
||||||
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||||
|
if (el.classList.contains('menu-open')) return;
|
||||||
const y = ev.clientY - el.getBoundingClientRect().top;
|
const y = ev.clientY - el.getBoundingClientRect().top;
|
||||||
const shown = el.classList.contains('head-peek');
|
const shown = el.classList.contains('head-peek');
|
||||||
// It takes less to hold the bar open than to open it, so a bar that
|
// It takes less to hold the bar open than to open it, so a bar that
|
||||||
@@ -651,6 +654,7 @@ function buildTile(tile) {
|
|||||||
|
|
||||||
el.addEventListener('pointerleave', () => {
|
el.addEventListener('pointerleave', () => {
|
||||||
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||||
|
if (el.classList.contains('menu-open')) return; // the pointer is on its way to the menu
|
||||||
el.classList.remove('head-peek');
|
el.classList.remove('head-peek');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -658,7 +662,7 @@ function buildTile(tile) {
|
|||||||
applyHeadState(tile, el);
|
applyHeadState(tile, el);
|
||||||
grid.appendChild(el);
|
grid.appendChild(el);
|
||||||
|
|
||||||
const entry = { el, body, dot, titleEl: title, controls, instance: null };
|
const entry = { el, body, dot, titleEl: title, controls, instance: null, openMenus: new Set() };
|
||||||
tileEls.set(tile.id, entry);
|
tileEls.set(tile.id, entry);
|
||||||
applyRect(tile);
|
applyRect(tile);
|
||||||
|
|
||||||
@@ -676,6 +680,15 @@ function buildTile(tile) {
|
|||||||
|
|
||||||
function refreshEmptyState() {
|
function refreshEmptyState() {
|
||||||
emptyEl.hidden = tiles.length > 0;
|
emptyEl.hidden = tiles.length > 0;
|
||||||
|
if (emptyEl.hidden) return;
|
||||||
|
// Two different empties: a bare board, and a ProdDash with nothing to put
|
||||||
|
// on one — a packaged install starts without modules until Admin adds some.
|
||||||
|
const noModules = registry.size === 0;
|
||||||
|
if (noModules === emptyEl.classList.contains('no-modules')) return;
|
||||||
|
emptyEl.classList.toggle('no-modules', noModules);
|
||||||
|
emptyEl.innerHTML = noModules
|
||||||
|
? 'No modules yet — <a href="/admin">open Admin</a> and install some under Available modules.'
|
||||||
|
: 'No tiles yet — use “+ Add tile” to build this dashboard.<br>Every browser keeps its own layout.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── drag to move ───────────────────────────────────────────────────── */
|
/* ── drag to move ───────────────────────────────────────────────────── */
|
||||||
@@ -1416,34 +1429,60 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
menu.className = 'tile-menu';
|
menu.className = 'tile-menu';
|
||||||
menu.hidden = true;
|
menu.hidden = true;
|
||||||
|
|
||||||
|
/* The menu lives on the body, not in the bar. A folded-away bar
|
||||||
|
(auto-hide, fullscreen) carries a backdrop-filter and a transform,
|
||||||
|
and either one makes a fixed-positioned descendant position
|
||||||
|
itself against the bar instead of the viewport — the menu landed
|
||||||
|
a screen-height below its button, inside the tile's overflow, and
|
||||||
|
never showed. Its host carries the tile's data-module so the
|
||||||
|
module's scoped CSS still reaches the items. */
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.className = 'tile-menu-host';
|
||||||
|
if (man) host.dataset.module = man.id;
|
||||||
|
host.appendChild(menu);
|
||||||
|
|
||||||
|
const setOpen = (on) => {
|
||||||
|
if (on === !menu.hidden) return;
|
||||||
|
menu.hidden = !on;
|
||||||
|
btn.classList.toggle('is-open', on);
|
||||||
|
if (on) entry.openMenus.add(menu);
|
||||||
|
else entry.openMenus.delete(menu);
|
||||||
|
// an open menu holds a folded-away bar out; when the last one
|
||||||
|
// closes with the pointer elsewhere, the bar goes back
|
||||||
|
entry.el.classList.toggle('menu-open', entry.openMenus.size > 0);
|
||||||
|
if (!on && !entry.openMenus.size && !entry.el.matches(':hover')) entry.el.classList.remove('head-peek');
|
||||||
|
};
|
||||||
const open = () => {
|
const open = () => {
|
||||||
if (typeof build === 'function') {
|
if (typeof build === 'function') {
|
||||||
menu.innerHTML = '';
|
menu.innerHTML = '';
|
||||||
build(menu);
|
build(menu);
|
||||||
}
|
}
|
||||||
menu.hidden = false;
|
setOpen(true);
|
||||||
// fixed positioning (escapes the tile's overflow): under the
|
// under the button, right-aligned, kept whole inside the viewport
|
||||||
// button, right-aligned, clamped to the viewport
|
|
||||||
const r = btn.getBoundingClientRect();
|
const r = btn.getBoundingClientRect();
|
||||||
menu.style.top = Math.min(r.bottom + 6, window.innerHeight - 80) + 'px';
|
|
||||||
menu.style.left = '0px';
|
menu.style.left = '0px';
|
||||||
|
menu.style.top = '0px';
|
||||||
const w = menu.offsetWidth;
|
const w = menu.offsetWidth;
|
||||||
|
const h = menu.offsetHeight;
|
||||||
|
menu.style.top = Math.max(8, Math.min(r.bottom + 6, window.innerHeight - h - 8)) + 'px';
|
||||||
menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
|
menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
|
||||||
};
|
};
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (menu.hidden) open();
|
if (menu.hidden) open();
|
||||||
else menu.hidden = true;
|
else setOpen(false);
|
||||||
});
|
});
|
||||||
const onDocClick = (e) => {
|
const onDocClick = (e) => {
|
||||||
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) menu.hidden = true;
|
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) setOpen(false);
|
||||||
};
|
};
|
||||||
document.addEventListener('click', onDocClick);
|
document.addEventListener('click', onDocClick);
|
||||||
docListeners.add(onDocClick);
|
docListeners.add(onDocClick);
|
||||||
|
|
||||||
wrap.append(btn, menu);
|
wrap.append(btn);
|
||||||
entry.controls.appendChild(wrap);
|
entry.controls.appendChild(wrap);
|
||||||
|
document.body.appendChild(host);
|
||||||
headerEls.add(wrap);
|
headerEls.add(wrap);
|
||||||
|
headerEls.add(host);
|
||||||
return {
|
return {
|
||||||
button: btn,
|
button: btn,
|
||||||
menu,
|
menu,
|
||||||
@@ -1451,7 +1490,7 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
btn.textContent = text;
|
btn.textContent = text;
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
menu.hidden = true;
|
setOpen(false);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -1463,6 +1502,8 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
for (const handle of [...sseHandles]) handle.close();
|
for (const handle of [...sseHandles]) handle.close();
|
||||||
for (const el of headerEls) el.remove();
|
for (const el of headerEls) el.remove();
|
||||||
headerEls.clear();
|
headerEls.clear();
|
||||||
|
entry.openMenus.clear();
|
||||||
|
entry.el.classList.remove('menu-open');
|
||||||
for (const fn of docListeners) document.removeEventListener('click', fn);
|
for (const fn of docListeners) document.removeEventListener('click', fn);
|
||||||
docListeners.clear();
|
docListeners.clear();
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-2
@@ -445,8 +445,9 @@ body.menu-hidden #topbar {
|
|||||||
}
|
}
|
||||||
.tile-dd { position: relative; display: inline-flex; }
|
.tile-dd { position: relative; display: inline-flex; }
|
||||||
|
|
||||||
/* a module header menu — fixed-positioned by the shell so the tile's
|
/* a module header menu — hosted on the body (shell.js addMenu) so neither
|
||||||
overflow:hidden can't clip it */
|
the tile's overflow nor a folded-away bar's backdrop-filter can touch it */
|
||||||
|
.tile-menu-host { display: contents; }
|
||||||
.tile-menu {
|
.tile-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 70;
|
z-index: 70;
|
||||||
|
|||||||
@@ -277,6 +277,52 @@ function isUninstalled(id) {
|
|||||||
return moduleState(id).uninstalled === true;
|
return moduleState(id).uninstalled === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A packaged install that still carries modules in its app folder — every
|
||||||
|
* install made before 1.10.0 does — moves them into the data directory
|
||||||
|
* once, as installed copies, and drops the app-folder copies. Nothing
|
||||||
|
* changes for the operator: the same modules run with the same settings
|
||||||
|
* (config is keyed by module id), and from now on each updates on its own
|
||||||
|
* from the catalog. A compiled helper sitting in a bundled folder travels
|
||||||
|
* with it. A module the operator had uninstalled is simply dropped — the
|
||||||
|
* catalog still offers it, and its settings stay in modules.json.
|
||||||
|
*/
|
||||||
|
function unbundleModules() {
|
||||||
|
if (!PACKAGED || !fs.existsSync(BUNDLED_MODULES_DIR)) return;
|
||||||
|
const installed = new Map(readManifests(INSTALLED_MODULES_DIR, 'installed').map((m) => [m.id, m]));
|
||||||
|
let moved = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const man of readManifests(BUNDLED_MODULES_DIR, 'bundled')) {
|
||||||
|
if (isUninstalled(man.id)) continue;
|
||||||
|
const copy = installed.get(man.id);
|
||||||
|
if (copy && compareVersions(copy.version, man.version) >= 0) continue; // the data directory already has as good or better
|
||||||
|
const dest = path.join(INSTALLED_MODULES_DIR, man.id);
|
||||||
|
const staging = path.join(INSTALLED_MODULES_DIR, `.${man.id}.moving`);
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(INSTALLED_MODULES_DIR, { recursive: true });
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
fs.cpSync(man.dir, staging, { recursive: true }); // keeps modes: a compiled helper stays executable
|
||||||
|
fs.rmSync(dest, { recursive: true, force: true });
|
||||||
|
fs.renameSync(staging, dest);
|
||||||
|
moved += 1;
|
||||||
|
console.log(`[proddash] moved bundled module "${man.id}" v${man.version} into the data directory${copy ? ` (over the installed v${copy.version})` : ''}`);
|
||||||
|
} catch (err) {
|
||||||
|
failed += 1;
|
||||||
|
console.error(`[proddash] could not move bundled module "${man.id}" into the data directory:`, err?.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed) {
|
||||||
|
console.warn(`[proddash] ${failed} bundled module${failed === 1 ? '' : 's'} stay in the app folder for now — tried again at the next start`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.rmSync(BUNDLED_MODULES_DIR, { recursive: true, force: true });
|
||||||
|
console.log(`[proddash] the app folder carries no modules now${moved ? ` — ${moved} moved into the data directory` : ''}; the admin page's catalog installs and updates them`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[proddash] could not remove the app folder's modules:", err?.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function moduleState(id) {
|
function moduleState(id) {
|
||||||
const s = modulesConfig[id];
|
const s = modulesConfig[id];
|
||||||
return s && typeof s === 'object' ? s : {};
|
return s && typeof s === 'object' ? s : {};
|
||||||
@@ -910,9 +956,21 @@ async function fetchArchive() {
|
|||||||
signal: AbortSignal.timeout(30000),
|
signal: AbortSignal.timeout(30000),
|
||||||
headers: { 'User-Agent': `ProdDash/${SHELL_VERSION}` },
|
headers: { 'User-Agent': `ProdDash/${SHELL_VERSION}` },
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${REPO.owner}/${REPO.repo}@${REPO.branch}`);
|
const where = `${REPO.owner}/${REPO.repo}@${REPO.branch}`;
|
||||||
|
// A forge that wants a sign-in does not refuse: it redirects the download
|
||||||
|
// to its login page, which arrives as a 200 of HTML. Say that, not
|
||||||
|
// zlib's "incorrect header check".
|
||||||
|
if (res.redirected && /\/user\/login\b|\/login\b/.test(new URL(res.url).pathname)) {
|
||||||
|
throw new Error(`${FORGE.base} requires a sign-in to read ${where} — turn off "require sign-in to view" in Gitea, or make the repo readable without one`);
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${where}`);
|
||||||
const gz = Buffer.from(await res.arrayBuffer());
|
const gz = Buffer.from(await res.arrayBuffer());
|
||||||
if (gz.length > MAX_ARCHIVE_BYTES) throw new Error('archive is unexpectedly large');
|
if (gz.length > MAX_ARCHIVE_BYTES) throw new Error('archive is unexpectedly large');
|
||||||
|
if (gz.length < 2 || gz[0] !== 0x1f || gz[1] !== 0x8b) {
|
||||||
|
const type = String(res.headers.get('content-type') || 'unknown type').split(';')[0].trim();
|
||||||
|
const looksHtml = /^\s*<(!doctype|html)/i.test(gz.subarray(0, 64).toString('utf8'));
|
||||||
|
throw new Error(`${FORGE.base} answered with ${looksHtml ? 'a web page' : type} instead of the ${where} archive — check the forge address and that the repo is readable without a sign-in`);
|
||||||
|
}
|
||||||
const { files, comment } = parseTar(zlib.gunzipSync(gz));
|
const { files, comment } = parseTar(zlib.gunzipSync(gz));
|
||||||
const stripped = new Map(); // drop the archive's single top-level folder
|
const stripped = new Map(); // drop the archive's single top-level folder
|
||||||
for (const [p, data] of files) {
|
for (const [p, data] of files) {
|
||||||
@@ -1235,9 +1293,18 @@ const DATA_DIR_INSIDE_ROOT = (() => {
|
|||||||
return rel.split(path.sep).join('/') + '/';
|
return rel.split(path.sep).join('/') + '/';
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
/* A packaged install — the macOS app, the Windows bundle: anything that is
|
||||||
|
not a git checkout, the same test the updater uses to choose the archive
|
||||||
|
over git pull — carries no modules of its own. Every module comes from
|
||||||
|
the catalog into the data directory, and the shell's own update never
|
||||||
|
brings one along. A checkout keeps running the modules in its tree, which
|
||||||
|
is what development wants. */
|
||||||
|
const PACKAGED = !fs.existsSync(path.join(ROOT, '.git'));
|
||||||
|
|
||||||
function isKeptOnUpdate(rel) {
|
function isKeptOnUpdate(rel) {
|
||||||
if (rel.startsWith('.git/')) return true;
|
if (rel.startsWith('.git/')) return true;
|
||||||
if (rel === 'config/modules.json' || rel.startsWith('config/layouts/')) return true;
|
if (rel === 'config/modules.json' || rel.startsWith('config/layouts/')) return true;
|
||||||
|
if (PACKAGED && (rel === 'modules' || rel.startsWith('modules/'))) return true;
|
||||||
return Boolean(DATA_DIR_INSIDE_ROOT) && `${rel}/`.startsWith(DATA_DIR_INSIDE_ROOT);
|
return Boolean(DATA_DIR_INSIDE_ROOT) && `${rel}/`.startsWith(DATA_DIR_INSIDE_ROOT);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1580,6 +1647,29 @@ async function handleAdminApi(req, res, urlPath) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Several at once — a fresh install picking its set. One result per id;
|
||||||
|
// a failure stops nothing else.
|
||||||
|
if (urlPath === '/api/admin/modules/install' && req.method === 'POST') {
|
||||||
|
if (refuseAdminWrite(req, res)) return;
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
body = JSON.parse((await readBody(req)) || '{}');
|
||||||
|
} catch {
|
||||||
|
return sendJson(res, 400, { error: 'Malformed request.' });
|
||||||
|
}
|
||||||
|
const ids = Array.isArray(body.ids) ? [...new Set(body.ids.map((id) => String(id)))] : [];
|
||||||
|
if (!ids.length) return sendJson(res, 400, { error: 'Nothing to install.' });
|
||||||
|
const results = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
results.push({ ok: true, ...(await installModule(id)) });
|
||||||
|
} catch (err) {
|
||||||
|
results.push({ ok: false, id, error: err?.message || String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sendJson(res, 200, { ok: results.every((r) => r.ok), results });
|
||||||
|
}
|
||||||
|
|
||||||
const install = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/install$/);
|
const install = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/install$/);
|
||||||
if (install && req.method === 'POST') {
|
if (install && req.method === 'POST') {
|
||||||
if (refuseAdminWrite(req, res)) return;
|
if (refuseAdminWrite(req, res)) return;
|
||||||
@@ -2074,6 +2164,7 @@ server.requestTimeout = 0;
|
|||||||
|
|
||||||
/* ── boot ───────────────────────────────────────────────────────────── */
|
/* ── boot ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
unbundleModules();
|
||||||
discoverModules();
|
discoverModules();
|
||||||
mountAllModules();
|
mountAllModules();
|
||||||
|
|
||||||
|
|||||||
@@ -152,10 +152,39 @@ const server = http.createServer((req, res) => {
|
|||||||
if (p === '/v1/timers') return sendJson(res, TIMERS.filter((t) => !DROP.has(t.id.uuid)));
|
if (p === '/v1/timers') return sendJson(res, TIMERS.filter((t) => !DROP.has(t.id.uuid)));
|
||||||
if (p === '/v1/timers/current') return sendJson(res, currentTimes().filter((t) => !DROP.has(t.id.uuid)));
|
if (p === '/v1/timers/current') return sendJson(res, currentTimes().filter((t) => !DROP.has(t.id.uuid)));
|
||||||
if (p === '/v1/timecode/status' && !NO_LTC) return sendJson(res, timecodeStatus());
|
if (p === '/v1/timecode/status' && !NO_LTC) return sendJson(res, timecodeStatus());
|
||||||
|
if (p === '/v1/transport/presentation/current') return sendJson(res, transport().current);
|
||||||
|
if (p === '/v1/transport/presentation/time') return sendJson(res, transport().time);
|
||||||
|
|
||||||
sendJson(res, { error: 'not found' }, 404);
|
sendJson(res, { error: 'not found' }, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ── transport (video countdown): a 4-minute cycle ─────────────────
|
||||||
|
0–60 s "Sermon Bumper.mp4" (3:00) playing
|
||||||
|
60–75 s the same bumper paused at 1:00
|
||||||
|
75–90 s playing again
|
||||||
|
90–120 s nothing loaded
|
||||||
|
120–240 s "Autumn Loop.mp4" (0:15) playing and wrapping — the module
|
||||||
|
must infer looping from the position jumping back.
|
||||||
|
Shapes match ProPresenter 21.4 live: `current` = { is_playing, uuid,
|
||||||
|
name, artist, audio_only, duration }, `time` = bare seconds (float). */
|
||||||
|
function transport() {
|
||||||
|
const phase = ((Date.now() - startedAt) / 1000) % 240;
|
||||||
|
const none = { current: { is_playing: false, uuid: '', name: '', artist: '', audio_only: false, duration: 0 }, time: -1 };
|
||||||
|
if (phase < 90) {
|
||||||
|
const playing = phase < 60 || phase >= 75;
|
||||||
|
const pos = Math.min(phase, 60) + (phase >= 75 ? phase - 75 : 0);
|
||||||
|
return {
|
||||||
|
current: { is_playing: playing, uuid: 'MEDIA-BUMPER', name: 'Sermon Bumper.mp4', artist: '', audio_only: false, duration: 180 },
|
||||||
|
time: Math.round(pos * 10) / 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (phase < 120) return none;
|
||||||
|
return {
|
||||||
|
current: { is_playing: true, uuid: 'MEDIA-LOOP', name: 'Autumn Loop.mp4', artist: '', audio_only: false, duration: 15 },
|
||||||
|
time: Math.round(((phase - 120) % 15) * 10) / 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */
|
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */
|
||||||
|
|
||||||
const cryptoMod = require('crypto');
|
const cryptoMod = require('crypto');
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A packaged ProdDash carries no modules. This drives a packaged copy of the
|
||||||
|
* shell — server.js and its client in a folder with no .git — through the
|
||||||
|
* three things that make that true:
|
||||||
|
*
|
||||||
|
* 1. an install made before 1.10.0 still has modules in its app folder:
|
||||||
|
* at boot they move into the data directory once, executables keeping
|
||||||
|
* their mode, an uninstalled one is dropped, and the app folder's
|
||||||
|
* modules/ is gone;
|
||||||
|
* 2. a shell update from an archive that carries modules/ never writes
|
||||||
|
* them into the app folder — they show up in the catalog instead;
|
||||||
|
* 3. a fresh install lists everything the repo offers, and installing
|
||||||
|
* several at once lands them in the data directory and mounts them.
|
||||||
|
*
|
||||||
|
* node tools/unbundled.test.js
|
||||||
|
*
|
||||||
|
* Plain Node, no dependencies: a mock forge, a temp app folder, a temp data
|
||||||
|
* directory. Exits non-zero on the first failure and cleans up after itself.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { spawn, execFileSync } = require('node:child_process');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const http = require('node:http');
|
||||||
|
const net = require('node:net');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const REPO = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
function freePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const probe = net.createServer();
|
||||||
|
probe.listen(0, '127.0.0.1', () => {
|
||||||
|
const { port } = probe.address();
|
||||||
|
probe.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
probe.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(url, ms = 15000) {
|
||||||
|
const deadline = Date.now() + ms;
|
||||||
|
let last = '';
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (r.ok) return;
|
||||||
|
last = 'HTTP ' + r.status;
|
||||||
|
} catch (err) {
|
||||||
|
last = err.message;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
throw new Error('server did not come up: ' + last);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeModule(dir, id, version, extra = {}) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({ id, name: id[0].toUpperCase() + id.slice(1), version, client: 'client.js', ...extra }));
|
||||||
|
fs.writeFileSync(path.join(dir, 'client.js'), 'export default function () {}\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The repo as a forge would archive it: one top-level folder, the shell files, and modules/. */
|
||||||
|
function buildArchive(dir) {
|
||||||
|
const top = path.join(dir, 'ProdDash-main');
|
||||||
|
fs.mkdirSync(top, { recursive: true });
|
||||||
|
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(top, 'server.js'));
|
||||||
|
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
|
||||||
|
writeModule(path.join(top, 'modules', 'demo'), 'demo', '2.0.0');
|
||||||
|
writeModule(path.join(top, 'modules', 'other'), 'other', '1.0.0');
|
||||||
|
writeModule(path.join(top, 'modules', 'third'), 'third', '1.0.0');
|
||||||
|
const out = path.join(dir, 'main.tar.gz');
|
||||||
|
execFileSync('tar', ['-czf', out, '-C', dir, 'ProdDash-main']);
|
||||||
|
return fs.readFileSync(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A packaged install as it was before 1.10.0: the shell plus bundled modules, no .git. */
|
||||||
|
function buildPackagedApp(app) {
|
||||||
|
fs.mkdirSync(path.join(app, 'config'), { recursive: true });
|
||||||
|
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(app, 'server.js'));
|
||||||
|
fs.copyFileSync(path.join(REPO, 'package.json'), path.join(app, 'package.json'));
|
||||||
|
fs.cpSync(path.join(REPO, 'public'), path.join(app, 'public'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(app, 'config', 'proddash.json'), JSON.stringify({ port: 24500 }));
|
||||||
|
writeModule(path.join(app, 'modules', 'demo'), 'demo', '1.0.0');
|
||||||
|
fs.mkdirSync(path.join(app, 'modules', 'demo', 'bin'));
|
||||||
|
fs.writeFileSync(path.join(app, 'modules', 'demo', 'bin', 'demo-helper'), '#!/bin/sh\necho helper\n');
|
||||||
|
fs.chmodSync(path.join(app, 'modules', 'demo', 'bin', 'demo-helper'), 0o755);
|
||||||
|
writeModule(path.join(app, 'modules', 'gone'), 'gone', '1.0.0');
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-unbundled-'));
|
||||||
|
const app = path.join(work, 'app');
|
||||||
|
const dataDir = path.join(work, 'data');
|
||||||
|
fs.mkdirSync(dataDir);
|
||||||
|
buildPackagedApp(app);
|
||||||
|
// The operator had uninstalled "gone" before this update.
|
||||||
|
fs.writeFileSync(path.join(dataDir, 'modules.json'), JSON.stringify({ gone: { uninstalled: true } }));
|
||||||
|
const archive = buildArchive(path.join(work, 'forge'));
|
||||||
|
|
||||||
|
const forge = http.createServer((req, res) => {
|
||||||
|
if (req.url.startsWith('/michael/ProdDash/archive/main.tar.gz')) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/gzip' });
|
||||||
|
return res.end(archive);
|
||||||
|
}
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
const forgePort = await freePort();
|
||||||
|
await new Promise((r) => forge.listen(forgePort, '127.0.0.1', r));
|
||||||
|
|
||||||
|
const port = await freePort();
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
PRODDASH_DATA_DIR: dataDir,
|
||||||
|
PORT: String(port),
|
||||||
|
PRODDASH_FORGE: `http://127.0.0.1:${forgePort}`,
|
||||||
|
PRODDASH_REPO: 'michael/ProdDash',
|
||||||
|
PRODDASH_NO_REMOTE_CHECK: '1',
|
||||||
|
PRODDASH_LAUNCHER: '1', // a supervisor is watching: an update exits 75 instead of respawning
|
||||||
|
};
|
||||||
|
let log = '';
|
||||||
|
let child = null;
|
||||||
|
const start = () => {
|
||||||
|
child = spawn(process.execPath, ['server.js'], { cwd: app, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
child.stdout.on('data', (d) => { log += d; });
|
||||||
|
child.stderr.on('data', (d) => { log += d; });
|
||||||
|
return child;
|
||||||
|
};
|
||||||
|
const exited = (proc) => new Promise((resolve) => proc.on('exit', resolve));
|
||||||
|
const api = async (p, init = {}) => {
|
||||||
|
const r = await fetch(base + p, { headers: { 'Content-Type': 'application/json' }, ...init });
|
||||||
|
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||||
|
};
|
||||||
|
const moduleIds = async () => (await api('/api/modules')).body.modules.map((m) => m.id).sort();
|
||||||
|
|
||||||
|
let failed = false;
|
||||||
|
try {
|
||||||
|
// ── 1. the move ──
|
||||||
|
start();
|
||||||
|
await waitFor(base + '/api/theme');
|
||||||
|
const moved = path.join(dataDir, 'modules', 'demo');
|
||||||
|
assert.ok(fs.existsSync(path.join(moved, 'module.json')), 'demo moved into the data directory');
|
||||||
|
const helper = path.join(moved, 'bin', 'demo-helper');
|
||||||
|
assert.ok(fs.existsSync(helper), 'its compiled helper came along');
|
||||||
|
assert.ok(fs.statSync(helper).mode & 0o111, 'and is still executable');
|
||||||
|
assert.equal(fs.existsSync(path.join(dataDir, 'modules', 'gone')), false, 'an uninstalled module is dropped, not moved');
|
||||||
|
assert.equal(fs.existsSync(path.join(app, 'modules')), false, "the app folder's modules/ is gone");
|
||||||
|
assert.deepEqual(await moduleIds(), ['demo'], 'demo runs, gone does not');
|
||||||
|
assert.match(log, /moved bundled module "demo" v1\.0\.0 into the data directory/);
|
||||||
|
assert.match(log, /the app folder carries no modules now/);
|
||||||
|
const state = (await api('/api/admin/state')).body;
|
||||||
|
assert.equal(state.modules.find((m) => m.id === 'demo')?.source, 'installed', 'demo is an installed copy now');
|
||||||
|
|
||||||
|
// The catalog sees the repo's modules/ — as offers, not as a bundle.
|
||||||
|
let cat = (await api('/api/admin/catalog?refresh=1')).body;
|
||||||
|
assert.equal(cat.shell.remote.error, '', 'the mock forge answers: ' + cat.shell.remote.error);
|
||||||
|
assert.deepEqual(cat.updates.map((u) => `${u.id} ${u.installedVersion}->${u.version}`), ['demo 1.0.0->2.0.0']);
|
||||||
|
assert.deepEqual(cat.available.map((a) => a.id).sort(), ['gone', 'other', 'third'].filter((id) => id !== 'gone'), 'other and third are on offer');
|
||||||
|
|
||||||
|
// ── 2. a shell update carries no modules ──
|
||||||
|
const running = child;
|
||||||
|
const up = await api('/api/admin/update', { method: 'POST', body: '{}' });
|
||||||
|
assert.equal(up.status, 200, 'update: ' + JSON.stringify(up.body));
|
||||||
|
assert.equal(await exited(running), 75, 'the server asks its supervisor for a restart');
|
||||||
|
assert.equal(fs.existsSync(path.join(app, 'modules')), false, 'the archive\'s modules/ was not written into the app folder');
|
||||||
|
assert.equal(JSON.parse(fs.readFileSync(path.join(app, 'package.json'), 'utf8')).version, '9.9.9', 'but the shell itself was updated');
|
||||||
|
|
||||||
|
log = '';
|
||||||
|
start();
|
||||||
|
await waitFor(base + '/api/theme');
|
||||||
|
assert.doesNotMatch(log, /moved bundled module/, 'nothing left to move on the second start');
|
||||||
|
assert.deepEqual(await moduleIds(), ['demo'], 'still just demo, from the data directory');
|
||||||
|
cat = (await api('/api/admin/catalog')).body;
|
||||||
|
assert.deepEqual(cat.available.map((a) => a.id).sort(), ['other', 'third'], 'the new modules are offers, not installs');
|
||||||
|
|
||||||
|
// ── 3. several at once ──
|
||||||
|
const many = await api('/api/admin/modules/install', { method: 'POST', body: JSON.stringify({ ids: ['other', 'third', 'nope'] }) });
|
||||||
|
assert.equal(many.status, 200);
|
||||||
|
assert.equal(many.body.ok, false, 'one of them does not exist');
|
||||||
|
assert.deepEqual(many.body.results.map((r) => `${r.id}:${r.ok}`), ['other:true', 'third:true', 'nope:false']);
|
||||||
|
assert.ok(fs.existsSync(path.join(dataDir, 'modules', 'other', 'module.json')), 'other landed in the data directory');
|
||||||
|
assert.deepEqual(await moduleIds(), ['demo', 'other', 'third'], 'and both are mounted');
|
||||||
|
const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' });
|
||||||
|
assert.equal(empty.status, 400, 'nothing to install is said');
|
||||||
|
|
||||||
|
console.log('unbundled shell: every check passed');
|
||||||
|
} catch (err) {
|
||||||
|
failed = true;
|
||||||
|
console.error('FAIL:', err.message);
|
||||||
|
console.error('--- last of the server log ---\n' + log.slice(-2500));
|
||||||
|
} finally {
|
||||||
|
try { child?.kill('SIGTERM'); } catch { /* gone */ }
|
||||||
|
forge.close();
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
fs.rmSync(work, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The update check against a forge that misbehaves: the admin page must say
|
||||||
|
* what went wrong in words, never zlib's "incorrect header check".
|
||||||
|
*
|
||||||
|
* node tools/update-check.test.js
|
||||||
|
*
|
||||||
|
* Plain Node, no dependencies. Starts a mock forge whose answer to the
|
||||||
|
* branch-archive download can be switched at runtime, starts server.js on a
|
||||||
|
* spare port pointed at it, and reads the shell status the admin page shows
|
||||||
|
* after each kind of answer:
|
||||||
|
*
|
||||||
|
* - a Gitea that requires a sign-in redirects the download to its login
|
||||||
|
* page (a 200 of HTML) — the case that hit both booths on 2026-09-29;
|
||||||
|
* - a captive portal or a wrong address answers HTML outright;
|
||||||
|
* - a plain HTTP error;
|
||||||
|
* - and a real archive, which must still parse and offer the update.
|
||||||
|
*
|
||||||
|
* Exits non-zero on the first failure and always stops what it started.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { spawn, execFileSync } = require('node:child_process');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const http = require('node:http');
|
||||||
|
const net = require('node:net');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
function freePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const probe = net.createServer();
|
||||||
|
probe.listen(0, '127.0.0.1', () => {
|
||||||
|
const { port } = probe.address();
|
||||||
|
probe.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
probe.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(url, ms = 15000) {
|
||||||
|
const deadline = Date.now() + ms;
|
||||||
|
let last = '';
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (r.ok) return;
|
||||||
|
last = 'HTTP ' + r.status;
|
||||||
|
} catch (err) {
|
||||||
|
last = err.message;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
throw new Error('server did not come up: ' + last);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A branch archive the way a forge would build one: one top-level folder. */
|
||||||
|
function buildArchive(dir) {
|
||||||
|
const top = path.join(dir, 'ProdDash-main');
|
||||||
|
fs.mkdirSync(path.join(top, 'modules', 'demo'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
|
||||||
|
fs.writeFileSync(path.join(top, 'modules', 'demo', 'module.json'), JSON.stringify({ id: 'demo', name: 'Demo', version: '1.0.0', client: 'client.js' }));
|
||||||
|
fs.writeFileSync(path.join(top, 'modules', 'demo', 'client.js'), 'export default function () {}\n');
|
||||||
|
const out = path.join(dir, 'main.tar.gz');
|
||||||
|
execFileSync('tar', ['-czf', out, '-C', dir, 'ProdDash-main']);
|
||||||
|
return fs.readFileSync(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-update-test-'));
|
||||||
|
const dataDir = path.join(work, 'data');
|
||||||
|
fs.mkdirSync(dataDir);
|
||||||
|
const archive = buildArchive(path.join(work, 'forge'));
|
||||||
|
|
||||||
|
// ── the mock forge ──
|
||||||
|
let mode = 'ok';
|
||||||
|
const LOGIN_PAGE = '<!DOCTYPE html>\n<html><head><title>Sign in</title></head><body>Sign in to continue</body></html>';
|
||||||
|
const forge = http.createServer((req, res) => {
|
||||||
|
const url = new URL(req.url, 'http://forge.invalid');
|
||||||
|
if (url.pathname === '/user/login') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
|
return res.end(LOGIN_PAGE);
|
||||||
|
}
|
||||||
|
if (url.pathname !== '/michael/ProdDash/archive/main.tar.gz') {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
return res.end('not here');
|
||||||
|
}
|
||||||
|
switch (mode) {
|
||||||
|
case 'login-redirect':
|
||||||
|
res.writeHead(303, { Location: '/user/login?redirect_to=' + encodeURIComponent(url.pathname) });
|
||||||
|
return res.end();
|
||||||
|
case 'html':
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
|
return res.end('<html><body>You must log in to this network first</body></html>');
|
||||||
|
case 'notfound':
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
return res.end('no such branch');
|
||||||
|
default:
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/gzip' });
|
||||||
|
return res.end(archive);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const forgePort = await freePort();
|
||||||
|
await new Promise((r) => forge.listen(forgePort, '127.0.0.1', r));
|
||||||
|
const forgeBase = `http://127.0.0.1:${forgePort}`;
|
||||||
|
|
||||||
|
// ── ProdDash, pointed at it ──
|
||||||
|
const port = await freePort();
|
||||||
|
const child = spawn(process.execPath, ['server.js'], {
|
||||||
|
cwd: ROOT,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PRODDASH_DATA_DIR: dataDir,
|
||||||
|
PORT: String(port),
|
||||||
|
PRODDASH_FORGE: forgeBase,
|
||||||
|
PRODDASH_REPO: 'michael/ProdDash',
|
||||||
|
PRODDASH_NO_REMOTE_CHECK: '1',
|
||||||
|
PRODDASH_LAUNCHER: '1',
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
let log = '';
|
||||||
|
child.stdout.on('data', (d) => { log += d; });
|
||||||
|
child.stderr.on('data', (d) => { log += d; });
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
|
||||||
|
/** What the admin page shows after a forced check. Admin is open: a fresh data dir has no PIN. */
|
||||||
|
const check = async (m) => {
|
||||||
|
mode = m;
|
||||||
|
const r = await fetch(base + '/api/admin/catalog?refresh=1');
|
||||||
|
assert.equal(r.status, 200, `catalog: HTTP ${r.status}`);
|
||||||
|
const body = await r.json();
|
||||||
|
return body.shell;
|
||||||
|
};
|
||||||
|
|
||||||
|
let failed = false;
|
||||||
|
try {
|
||||||
|
await waitFor(base + '/api/theme');
|
||||||
|
|
||||||
|
let shell = await check('login-redirect');
|
||||||
|
assert.match(shell.remote.error, /requires a sign-in/, 'a login redirect is named as such: ' + shell.remote.error);
|
||||||
|
assert.match(shell.remote.error, /require sign-in to view/, 'and the Gitea setting to look at is named');
|
||||||
|
assert.doesNotMatch(shell.remote.error, /header check/, 'never zlib\'s words');
|
||||||
|
assert.equal(shell.updateAvailable, false);
|
||||||
|
|
||||||
|
shell = await check('html');
|
||||||
|
assert.match(shell.remote.error, /answered with a web page instead of the michael\/ProdDash@main archive/, shell.remote.error);
|
||||||
|
|
||||||
|
shell = await check('notfound');
|
||||||
|
assert.match(shell.remote.error, /HTTP 404 fetching michael\/ProdDash@main/, shell.remote.error);
|
||||||
|
|
||||||
|
shell = await check('ok');
|
||||||
|
assert.equal(shell.remote.error, '', 'a real archive clears the error: ' + shell.remote.error);
|
||||||
|
assert.equal(shell.remote.version, '9.9.9', 'and is read');
|
||||||
|
assert.equal(shell.updateAvailable, true, 'and offered');
|
||||||
|
|
||||||
|
// Back to a broken forge: the last good read is not mistaken for a current one.
|
||||||
|
shell = await check('login-redirect');
|
||||||
|
assert.match(shell.remote.error, /requires a sign-in/);
|
||||||
|
|
||||||
|
console.log('update check: every check passed');
|
||||||
|
} catch (err) {
|
||||||
|
failed = true;
|
||||||
|
console.error('FAIL:', err.message);
|
||||||
|
console.error('--- last of the server log ---\n' + log.slice(-2000));
|
||||||
|
} finally {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
forge.close();
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
fs.rmSync(work, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user