Compare commits

...
Author SHA1 Message Date
Waters ChurchandClaude Fable 5.1 26e47c2fdd ProdCom 1.5.0: start ProdCom when it is offline, and turn its API on
ProdCom is a sandboxed Mac App Store app whose HTTP API lives inside it,
behind the UserDefaults switch applicationAPIEnabled — absent means off,
and a running ProdCom does not notice the switch changing; it reads it
at launch. So when the tile says "unreachable" on a booth where ProdCom
runs on the same Mac, there are two causes and two fixes: ProdCom is not
running (open it), or it is running with the API off (set the switch,
quit it, open it again).

The module's server now does both, behind two Admin switches, both on:
"Start ProdCom when it is offline" and "Restart ProdCom to turn its API
on". On a connection-level failure (never an HTTP answer) the supervisor
runs up to three attempts five seconds apart: not running → open -g -b;
running with the switch off → write it, read it back, ask ProdCom to
quit (Apple Event, SIGTERM after 5 s), open it again — at most once per
outage; running with the switch on → keep probing, and only blame the
port if that is still true on the third look, because a quitting ProdCom
still shows in pgrep. A successful run nudges the module to reconnect at
once. It gives up with a tile message until ProdCom answers again, and
does nothing at all for a ProdCom on another machine or off macOS.

The launcher gains the Apple Events entitlement and usage string so the
polite quit can be granted; if it is denied, SIGTERM does the job.

Verified live on the dev Mac against ProdCom 2.3.4: not running + switch
absent → opened, switch written, restarted, Live after two attempts; an
operator's Cmd-Q → reopened. 71 fake-driven tests (node --test), the OS
layer included through an injectable execFile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 15:50:17 -04:00
michael 485482531d Merge pull request 'Overflow Tap 1.0.0: switch Overflow Tap destinations from keywords in ProPresenter slide notes' (#47) from claude/overflow-tap-module into main 2026-10-27 14:28:21 -04:00
Waters Church 7bb852b1c8 Merge remote-tracking branch 'origin/main' into claude/overflow-tap-module 2026-10-27 14:27:33 -04:00
michael f800c1867e Merge pull request 'ProPresenter 1.2.0: the live pill shows the audience look' (#56) from claude/propresenter-now-audience-look-028ef8 into main 2026-10-27 13:12:15 -04:00
Waters ChurchandClaude Fable 5.1 7c4217a23a ProPresenter 1.2.0: the live pill shows the audience look
The Now / Next and Now tiles' header pill now carries the name of the
Audience Look live in ProPresenter where it used to say "Live". The
module reads /v1/look/current once a second (and the saved list every
tenth poll) with the feed's own endpoint and password, and sends the
look as an additive `look` field in its stream and snapshot. A single
slow or failed read keeps the last name; the feed's own reachable state
is never touched. With no look to show the pill falls back to "Live".
The gear switch is relabelled "Audience look" (same key, so saved
layouts keep working).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 13:08:44 -04:00
christian 5ed2137dd5 Merge pull request 'ProPresenter Look 1.0.0: a new module showing the live audience look' (#55) from claude/propresenter-looks into main 2026-10-26 13:05:51 -04:00
christian 49f8a82fbd ProPresenter Look 1.0.0: mock ProPresenter looks API 2026-10-26 12:58:39 -04:00
christian 70f56c7909 ProPresenter Look 1.0.0: README 2026-10-26 12:58:25 -04:00
christian cd13ecaf7c ProPresenter Look 1.0.0: stylesheet 2026-10-26 12:58:12 -04:00
christian 09f59c1bef ProPresenter Look 1.0.0: client entry 2026-10-26 12:57:53 -04:00
christian 3bfea6010a ProPresenter Look 1.0.0: server entry 2026-10-26 12:57:40 -04:00
christian 7c0579cbf9 ProPresenter Look 1.0.0: manifest 2026-10-26 12:57:36 -04:00
Waters ChurchandClaude Fable 5.1 6fffefc6c0 Overflow Tap: the tile says what Tap is on; the corner dot says how it went
The "TAP IS ON · since …" line (and its SWITCHING / NOT SWITCHED forms, and
the red error line under the destination) are gone from the tile. A tile
shows the keyword Tap is on, its destination, the groups moved, the slide
chip and the trail; status is the corner dot's job, as everywhere else in
ProdDash.

The dot now follows the retries honestly: a switch Overflow refuses is
retried three times, ten seconds apart, with the dot amber ("retrying (n of
4 attempts failed)") while the module is still fixing it itself; only when
the attempts are spent does the dot go red with the reason on hover, and the
alert fire if it is switched on. The budget counts refusals in a row across
keywords, not per keyword — a service keeps bringing new keywords, and each
one restarting a fresh budget meant a dead Overflow never turned the dot
red. The alert is raised once per outage, and a success clears the streak.
Verified against the mock with --fail-switch: amber through three refusals,
red on the fourth with one alert, later refusals stay red without
re-alerting, and green again on the first success once the mock is back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 13:57:45 -04:00
Waters Church 227e3bb846 Merge remote-tracking branch 'origin/main' into claude/overflow-tap-module
# Conflicts:
#	CLAUDE.md
#	README.md
2026-10-25 13:04:37 -04:00
christian c043834d1e Merge pull request 'Band Lineup 1.13.0: a photo per instrument, your own photos win, spots stay in reach while dragging' (#53) from claude/band-lineup-photos into main 2026-10-20 13:41:06 -04:00
christianandClaude Opus 5.5 1fd7dcdf1e Band Lineup 1.13.0: a photo per instrument, picked by the spot each person is in that week
DJ plays drums one week and sings the next; one photo can't fit both. A
person can now have a photo per instrument, named "Name - Instrument"
("DJ - Drums.jpg", "DJ - Vocals.jpg"), plus a plain "DJ.jpg" for
everything else. Sync reads the instrument family of the spot each person
lands in (its bracketed role, else their PCO position) and picks:

1. the photo picked by hand for them on that instrument;
2. a photo named for that instrument;
3. their everyday pick, else their best-named photo. A photo of them on
   another instrument still beats PCO's headshot.

Families keep the shorthands together: Vox/Vocals/Singer, Keys/Piano/Pad,
Drums/Perc, Bass, Guitar/Electric/Acoustic/Rhythm, Leader. A photo
dropped onto someone is remembered per person per instrument
("<id>|drums"), so a drum pick never overrides the vocal photo. A spot
naming several roles files under its most specific: Leader, then Vocals,
Drums, Keys, Bass, Guitar. Photo names from files now collapse the spaces
left by "Name - Instrument".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:40:00 -04:00
christianandClaude Opus 5.5 e59629694e Band Lineup 1.13.0: your own photos win, a photo dropped on someone becomes theirs, and the spots stay in reach while dragging
Photos. Sync fell back to the Planning Center headshot whenever two
library photos matched a person equally ("Mike" and "mike"), and had no
way to know "Rose" is Rosaliz — so the booth dragged those in by hand
every week. Now:
- Several equally good library photos is not a reason to use PCO's; the
  first of them is used. PCO's headshot is only for someone with no photo.
- Dropping a library photo onto the synced person already in that spot
  (same name, same first name, or a nickname sharing its first three
  letters) makes it their photo: they keep their Planning Center name and
  spot, and it is remembered (settings.json photoFor) for every later
  Sync. Photos already dragged in by hand for this plan are learned the
  same way on the next Sync.
- A synced person's library photo is read live, so replacing that photo
  updates the board without a re-sync.

Dragging. The editor was one scrolling column, so a photo found far down
the list had to be carried back up to its spot, and every drop redrew the
editor back to the top. The spots are now pinned in their own pane above
the photos, each pane auto-scrolls when a drag nears its edge, both keep
their scroll position through redraws, and a search box filters the
photos by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:28:49 -04:00
christian 6da6f4c31e Merge pull request 'Vista Cues 1.1.0: a wired-network setting; a sender set by name or off it shows red' (#52) from claude/vista-cues-wired-network into main 2026-10-20 13:27:42 -04:00
christian 8e4a0e849e Merge pull request 'Band Lineup 1.12.0: Sync uses Planning Center notes for mics, keeps your moves, and dragging works on touch' (#51) from claude/band-lineup-drag into main 2026-10-20 13:17:33 -04:00
christianandClaude Opus 5.5 df2f0494f8 Vista Cues 1.1.0: a wired-network setting; a sender set by name or off it shows red
The Macs at NA are also on Wi-Fi and the Dante networks, and Bonjour lists each MIDI session once
per network. On 2026-10-20 the Tracks and Companion senders were set to Wi-Fi addresses, so the
lighting MIDI ProdDash hears rode Wi-Fi. With "Wired network" set (This machine; e.g. 10.1.0.0/16,
several with commas), a sender typed as a name or as an address outside it, and a session that
joins us from outside it, turn the admin status line and the tile's sender chip red, naming the
sender. It still joins, so the cues keep showing. Empty (the default) changes nothing; a typo in
the setting fails the module in /admin with a plain message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:14:44 -04:00
christianandClaude Opus 5.5 0548cb54fa Band Lineup 1.12.0: Sync puts the leader and vocalists on the mic their Planning Center note names
The booth writes each vocalist's and the leader's mic into their Planning
Center note every week ("Mix 8, VOX 5", "Mix 1, Vox 1"), because those
change week to week and the position name ("Vocalist") carries no number.
Sync ignored the notes and dropped the singers onto Vox 2-5 in whatever
order Planning Center listed them.

Sync now reads each team member's note and places anyone whose note names
a spot there first: the mix number must agree with the slot's MIX, and the
role ("VOX 5", "Drums") is scored like a position. "NA" or a blank note
says nothing. Everyone else is placed by position as before. Tests use the
real Oct 25 and Nov 1 notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:14:39 -04:00
christianandClaude Opus 5.5 c1152b4ab3 Band Lineup 1.12.0: Sync keeps your moves and puts each person in one spot
Two Sync problems found by checking the real October 25 plan against the
board:

- Someone on the plan twice (Mike Powell is Leader and Guitar [Acoustic])
  could fill two spots, pushing a real player off the board. Which spot
  the duplicate took depended on the order Planning Center returned
  people, so it only happened some weeks. Assignment now uses each person
  once.
- Moving a synced person to another spot was undone by the next Sync,
  which re-placed everyone it had filled. A move is now a hand placement
  for the current plan and Sync leaves it alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:11:56 -04:00
christianandClaude Opus 5.5 a692b6a9e2 Band Lineup 1.12.0: dragging photos into spots works with a finger and stops dropping out
The editor used the browser's built-in drag-and-drop, which never fires for
a touchscreen, flickered its drop highlight over a card's inner parts, and
lost the drag whenever a board update arrived mid-drag (the editor was
rebuilt under the pointer).

Dragging is now driven by pointer events, one path for mouse and touch: a
mouse drags as soon as it moves, a finger holds still for a moment first so
a swipe still scrolls. A floating copy of the photo follows the pointer, the
spot under it lights up, the editor auto-scrolls near its edges, and board
updates wait until the drop. Tap a photo then tap a spot still works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:06:57 -04:00
christian e670e1ee72 Merge pull request 'Band Lineup 1.11.1: Sync replaces last week's hand-placed people' (#50) from claude/band-lineup-sync-replaces into main 2026-10-13 11:05:08 -04:00
christianandClaude Opus 5.5 4b0f161c93 Band Lineup 1.11.1: Sync replaces last week's hand-placed people
Sync only filled empty spots and spots it had filled itself, so anyone
dragged in by hand stayed forever — pressing Sync for a new week swapped
some photos and left others, depending on how each spot got filled.

Hand placements are now stamped with the plan they were made for. Sync
keeps the ones made for the plan it is loading (a sub dragged in this
week survives a re-sync) and replaces or clears the rest. The last
synced plan is saved so placements made after a restart stamp right.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:03:31 -04:00
christian dafb48d9ed Merge pull request 'Vista Cues 1.0.1: its own heading in the + picker instead of sitting under Slack' (#49) from claude/vista-cues-picker into main 2026-10-11 11:40:50 -04:00
Waters ChurchandClaude Fable 5.1 459d8b9c38 Overflow Tap: speak Overflow's Dashboard API, as its own helper app does
The first cut guessed a write route on Overflow's public API, which documents
only reads for Tap. With the helper app's bundle in hand (Overflow ProPresenter
Integration 1.2.3, read 2026-10-11) the module now makes exactly the calls the
helper makes, against the Dashboard's own backend:

  POST /nonprofit-dashboard/authentication/login/jwt      a Dashboard sign-in → JWT
  GET  /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords
                                                          the Keyword Mappings
  POST …/nonprofits/{id}/keywords?syncNonprofit=true      { keywords } = the switch

So the keyword → destination → Tap groups mapping stays where Overflow keeps
it (Dashboard › Integrations › ProPresenter › Keyword Mappings) and is read
from there: on start, every five minutes, on the tile's refresh button, and
from every trigger's answer. Admin config shrinks to the ProPresenter endpoint,
the Overflow Dashboard login (plus a Nonprofit ID only for an @overflow.co
staff login) and the alert switch; the API-key, Tap-groups, keyword-text and
match-mode fields are gone.

Behaviour follows the helper: both the presentation layer (/v1/status/slide)
and the announcement layer (/v1/announcement/slide_index + /active) are
watched; when a layer's set of matched keywords changes to a non-empty set it
is sent; matching is per word, case-insensitive, punctuation around the word
ignored. The session token is cached across re-inits for the same login so an
admin saving config repeatedly cannot trip Overflow's "too many attempts";
a 401 signs in again once; a refused sign-in is not retried within 20 s.

tools/overflow-tap-mock.js now plays the Dashboard API (any email/password
signs in; --fail-switch, --expire=<s>, --login=<email>:<password>) and gives
the mock ProPresenter an announcement layer (--no-announcements for an older
one). Verified against it: sign-in, keyword load, triggers on both layers,
token expiry → re-login, 500 → NOT SWITCHED + alert + retries, wrong login →
the reason on the tile and in the health line, and recovery. keywords.selftest.js
pins the matching rule (8 cases).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 10:25:02 -04:00
Waters ChurchandClaude Fable 5.1 6b51044be6 Overflow Tap 1.0.0: switch Overflow Tap destinations from keywords in ProPresenter slide notes
A new module that does what Overflow's "ProPresenter Integration" helper app
does, inside ProdDash: the live slide's notes are read from ProPresenter
(GET /v1/status/slide, twice a second) and when they carry one of the admin
page's keywords, the chosen Overflow Tap groups are pointed at that keyword's
destination. One tile: the keyword Tap is on (and since when), its
destination and the groups moved, a chip saying whether the slide on screen
carries a keyword, and the last four switches. A failed switch turns the tile
red with the reason, retries three times, and can flash an alert channel.

Admin config: ProPresenter endpoint + password; Overflow client ID + API key
(x-client-id / x-api-key, the public API's two headers); Tap groups to switch
(multiselect read from the account over /groups); keywords as text, one per
line (`give = Give Online`, optionally `@ Lobby, Kids Wing`); match mode
(whole word / anywhere / own line); alerts switch + channel.

The one unverified piece: Overflow's public API documents only reads for Tap
(OpenAPI checked 2026-10-07), while Overflow's helper uses the Dashboard's
private API. The switch is sent as PATCH /api/v3/tap/groups/{id} with the new
destinationId — the natural shape for this API — and every switch is confirmed
by reading the group back, so a refusal shows on the tile instead of passing
silently. overflow.js is the one place to change if Overflow names a
different route.

Also: tools/overflow-tap-mock.js (a ProPresenter whose slide notes carry
keywords on :1602 plus the Overflow Tap API on :24720, --read-only to fail
every switch), keywords.selftest.js (14 cases), README + CLAUDE.md entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:00:37 -04:00
37 changed files with 4713 additions and 134 deletions
+1
View File
@@ -53,6 +53,7 @@ title-bar controls). The rules that matter most:
`node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`).
`node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`).
`node tools/vista-midi-mock.js 5104` (a network MIDI / RTP-MIDI session for Vista Cues; point its first sender at `127.0.0.1:5104` and paste the cue sheet it prints; `--invite 127.0.0.1:5040` joins from its side, `--name`, `--channel`, `--every`, `--notes 2,23,8`).
`node tools/overflow-tap-mock.js` (ProPresenter slide notes on `127.0.0.1:1602` + the Overflow Dashboard API on `127.0.0.1:24720`; run ProdDash with `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720`, any email/password signs in; `--fail-switch`, `--expire=20`, `--login=<email>:<password>`).
## Reference code (read-only forks — don't develop these here)
+63 -10
View File
@@ -358,7 +358,13 @@ below. Nothing is installed without someone clicking.
at ProdCom (the tile's bottom edge lights up in the accent color), and the
channel the text goes out *as* is picked from their Send as menu — only
channels ProdCom lets text be typed onto are offered.
Admin config: ProdCom server (host : port), API key.
Admin config: ProdCom server (host : port), API key, and two switches for
a ProdCom on this Mac (macOS only): **Start ProdCom when it is offline**
opens it when it isn't running (up to three tries, five seconds apart), and
**Restart ProdCom to turn its API on** turns the Application API on and
restarts ProdCom when it was running with the API off — ProdCom reads that
switch only at launch, so the restart costs about ten seconds without
intercom. Neither does anything for a ProdCom on another machine.
Tile settings: channel icons, direction and timestamps.
- **ProPresenter** — what's live and what's next, with slide progress,
driven by the shared `propresenter-core` client (ProPresenter 7/20).
@@ -366,8 +372,8 @@ below. Nothing is installed without someone clicking.
the live item alone with the gradient filling the tile edge to edge — a
wall panel that says one thing.
Admin config: ProPresenter endpoint (host : port), password.
Tile settings: a **Show** group of switches for the playlist name, live
status, NOW and NEXT labels, slide count, progress bar, the next list and
Tile settings: a **Show** group of switches for the playlist name, audience
look, NOW and NEXT labels, slide count, progress bar, the next list and
its item tags, plus tidy titles.
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
elapsed), the **video countdown** (time left on what ProPresenter is
@@ -515,17 +521,57 @@ below. Nothing is installed without someone clicking.
read from the account), and a folded **Timing** group for the poll
intervals and a position trim.
Tile settings: cue name, previous cue, playhead, cue list.
- **Overflow Tap** — what Overflow's own "ProPresenter Integration" helper
app does, inside ProdDash, speaking the same Dashboard API it does: the
live slide's notes are read from ProPresenter (the presentation layer and
the announcement layer), and when they carry one of the keywords mapped in
the Overflow Dashboard, Overflow is told — and points that keyword's Tap
groups at its destination, so a tap on a disc follows what is on screen.
A slide with no keyword changes nothing. Keywords match as the helper
matches them: as a whole word of the notes, any case, punctuation around
it ignored (`give` fires on "Give!" and not on "forgiveness"). The tile is
one keyword, big: what Tap is on, its destination and the groups moved, a
chip saying whether the slide on screen carries a keyword, and the last
four switches with their times. How a switch went is the corner dot's
job: a switch Overflow refuses is retried three times with the dot left
alone, and only when those are spent does the dot go red (hover it for
the reason) and, if switched on in admin, an alert channel flash. Admin
config: the ProPresenter endpoint and an
**Overflow Dashboard login** (email and password; Google sign-in has no
password, so set one with "Forgot Your Password?"). The keywords, their
destinations and their Tap groups stay where Overflow keeps them —
Integrations › ProPresenter › Keyword Mappings in the Dashboard — and are
read from there on start, every few minutes, and on the tile's refresh
button. Tile settings are four switches: destination, groups, slide
match, history.
- **Band Lineup** — the backstage stage board: everyone on stage in their mic
slot with photo, name and mic/pack label, on one shared board that every
screen sees the same way — whoever edits it changes it everywhere, over SSE.
A photo library fills it: point the module at a Google Drive folder shared
"anyone with the link" (file name → person's name) or at a folder on the
ProdDash machine, and photos added or removed there follow. **Edit** in the
title bar opens that library — drag a photo onto a slot, drag between slots
to swap, rename or clear. Planning Center is optional auto-fill on top, not
the source of truth: **Sync** reads the upcoming plan's team members and
drops each into the slot matching their position, filling only slots nobody
placed by hand, so a guest pinned on Thursday survives Sunday's sync.
title bar opens that library — the spots pinned on top, the photos (with a
name search) scrolling below — drag a photo onto a slot (mouse, or press and
hold on a touchscreen), drag between slots to swap, rename or clear.
Dropping a photo onto the person already in a spot makes it *their* photo
for that instrument (a nickname like "Rose" for Rosaliz counts): they keep
their Planning Center name and spot, and every later Sync uses it whenever
they are on that instrument.
Planning Center is optional auto-fill on top, not the source of truth:
**Sync** reads the upcoming plan's team members and places each one. A
person whose Planning Center note names their mic ("Mix 8, VOX 5") goes to
that slot — this is how the leader and vocalists, whose position is just
"Vocalist", land on the right mic — and everyone else goes to the slot
matching their position, one slot per person. Photos come from the library
whenever it has one of them, and Planning Center's headshot only when it
has none. Someone who plays more than one thing can have a photo per
instrument, named `Name - Instrument` (`DJ - Drums.jpg`, `DJ - Vocals.jpg`;
Vox/Vocals, Keys/Piano, Drums, Bass, Guitar/Electric/Acoustic, Leader), and
a plain `DJ.jpg` for everything else: Sync uses the one for the spot they
are in that week. A photo dropped onto someone is remembered for that
instrument only. Anything placed or moved by
hand for that plan stays put through later syncs; last week's people are
replaced.
Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and
raining confetti, and a Choir button adds the extra spot for the weeks there
is one. Admin config: Planning Center credentials, service type and plan, the
@@ -598,8 +644,15 @@ board — a plan, its team members, birthdays and photos — run ProdDash with
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`) and
`node tools/vista-midi-mock.js 5104` (a network MIDI session playing a cue
sheet's notes, as ProPresenter does for Vista — point Vista Cues' first
sender at `127.0.0.1:5104` and paste the sheet it prints). See the testing section of
the [module guide](docs/MODULE-GUIDE.md).
sender at `127.0.0.1:5104` and paste the sheet it prints) and
`node tools/overflow-tap-mock.js` (a ProPresenter whose slide notes carry
keywords on `127.0.0.1:1602`, announcement layer included, plus the Overflow
Dashboard API on `127.0.0.1:24720` — run ProdDash with
`OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720` and sign the module in with
any email and password; `--fail-switch` refuses every switch,
`--expire=20` makes sessions expire, `--login=<email>:<password>` accepts
one login only). See the testing section of the
[module guide](docs/MODULE-GUIDE.md).
## Install as a desktop web app
+2
View File
@@ -33,6 +33,8 @@
verbatim, so they say what ProdDash does with each permission. -->
<key>NSMicrophoneUsageDescription</key>
<string>ProdDash reads an audio input on this Mac to decode SMPTE LTC timecode for the Timers module.</string>
<key>NSAppleEventsUsageDescription</key>
<string>ProdDash asks ProdCom to quit and relaunches it when its Application API has been switched off.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>ProdDash connects to production gear on this network — ProdCom, ProPresenter and anything else its modules are pointed at.</string>
<key>NSBonjourServices</key>
@@ -6,5 +6,10 @@
the hardened runtime that needs saying out loud. -->
<key>com.apple.security.device.audio-input</key>
<true/>
<!-- The ProdCom module asks ProdCom to quit (an Apple Event) before it
relaunches it with its API on; without this the hardened runtime
refuses the event outright. -->
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>
+144 -56
View File
@@ -1,5 +1,5 @@
// Band Lineup — client. Renders the shared stage board from /state + /stream.
// The Edit button opens an editor: an uploaded-photo library you drag (or click)
// The Edit button opens an editor: an uploaded-photo library you drag (or tap)
// into slots, plus rename / clear / upload / delete. Edits go to the server and
// come back over SSE, so every Band Lineup screen stays in sync. Birthday week
// (Wed→Tue) rains confetti.
@@ -14,26 +14,108 @@ export default function create({ root, moduleApi }) {
let selectedLibId = ''; // click-to-place fallback for touch
const canvases = new Set();
let raf = null;
// Auto-scroll the editor while dragging a photo (native DnD won't do it reliably).
let dragScrolling = false;
let dragY = 0;
let dragBody = null;
let dragRaf = null;
function startDragAutoScroll() {
if (dragRaf) return;
const tick = () => {
if (!dragScrolling || !dragBody || !dragBody.isConnected) { dragRaf = null; return; }
const r = dragBody.getBoundingClientRect();
const zone = 90; // generous edge band so you don't have to be pixel-accurate
let dy = 0;
if (dragY < r.top + zone) dy = -Math.ceil((r.top + zone - dragY) / 4) - 3;
else if (dragY > r.bottom - zone) dy = Math.ceil((dragY - (r.bottom - zone)) / 4) + 3;
if (dy) dragBody.scrollTop += dy;
dragRaf = requestAnimationFrame(tick);
};
dragRaf = requestAnimationFrame(tick);
/* ---------- drag (pointer events: one path for mouse, pen and touch) ----------
* Native HTML drag-and-drop never fires for a finger, drops onto a card's child elements
* flicker, and a board update arriving mid-drag rebuilt the editor out from under it. This
* drives the drag by hand instead: a mouse drags as soon as it moves; a finger holds still for
* a moment first (so a quick swipe still scrolls the editor). A floating copy of the photo
* follows the pointer, the spot under it lights up, and the editor auto-scrolls near its
* edges. Board updates that arrive during a drag wait until it ends. */
const DRAG_MOVE_PX = 6;
const TOUCH_HOLD_MS = 220;
let drag = null; // { kind: 'lib'|'slot', id, body, ghost, over, x, y, raf }
let pending = null; // a pointerdown that may become a drag
let renderHeld = false; // a state update arrived mid-drag; render when it ends
function slotUnder(x, y) {
const hit = document.elementFromPoint(x, y);
const cell = hit && hit.closest ? hit.closest('.bl-edit-slot') : null;
return cell && root.contains(cell) ? cell : null;
}
function stopDragAutoScroll() { dragScrolling = false; if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = null; } }
function beginDrag(p) {
const ghost = el('div', 'bl-drag-ghost');
ghost.dataset.module = 'band-lineup'; // lives on <body> (above every tile), so it carries its own scope
const src = p.source.querySelector('img');
if (src) { const img = document.createElement('img'); img.src = src.src; img.alt = ''; ghost.appendChild(img); }
else ghost.textContent = p.label || '';
document.body.appendChild(ghost);
drag = { kind: p.kind, id: p.id, body: p.body, ghost, over: null, x: p.x, y: p.y, raf: null, source: p.source };
p.source.classList.add('bl-dragging');
root.classList.add('bl-is-dragging');
moveDrag(p.x, p.y);
const tick = () => {
if (!drag) return;
// Auto-scroll whichever pane (spots or photos) the pointer is near the edge of.
const b = [...root.querySelectorAll('.bl-pane')].find((pane) => { const r = pane.getBoundingClientRect(); return drag.x >= r.left && drag.x <= r.right && drag.y >= r.top && drag.y <= r.bottom; });
if (b && b.scrollHeight > b.clientHeight) {
const r = b.getBoundingClientRect(); const zone = Math.min(60, r.height / 4); let dy = 0;
if (drag.y < r.top + zone) dy = -Math.ceil((r.top + zone - drag.y) / 4) - 2;
else if (drag.y > r.bottom - zone) dy = Math.ceil((drag.y - (r.bottom - zone)) / 4) + 2;
if (dy) { b.scrollTop += dy; moveDrag(drag.x, drag.y); }
}
drag.raf = requestAnimationFrame(tick);
};
drag.raf = requestAnimationFrame(tick);
}
function moveDrag(x, y) {
drag.x = x; drag.y = y;
drag.ghost.style.transform = `translate(${x}px, ${y}px)`;
const cell = slotUnder(x, y);
if (cell !== drag.over) {
if (drag.over) drag.over.classList.remove('bl-drop-over');
drag.over = cell;
if (cell) cell.classList.add('bl-drop-over');
}
}
function endDrag(drop) {
const d = drag; drag = null;
if (!d) return;
if (d.raf) cancelAnimationFrame(d.raf);
d.ghost.remove();
d.source.classList.remove('bl-dragging');
root.classList.remove('bl-is-dragging');
if (d.over) d.over.classList.remove('bl-drop-over');
if (drop && d.over) {
const to = d.over.dataset.index;
if (d.kind === 'lib') assign(to, d.id);
else if (d.id !== to) api(`/move?from=${d.id}&to=${to}`, { method: 'POST' });
}
if (renderHeld) { renderHeld = false; render(); }
}
function cancelPending() { if (pending && pending.timer) clearTimeout(pending.timer); pending = null; }
// Make `node` draggable. `kind`/`id` say what is being dragged.
function makeDraggable(node, kind, id, body, label) {
node.addEventListener('pointerdown', (e) => {
if (e.button !== 0 || drag) return;
if (e.target.closest('button')) return; // name / clear / delete buttons keep working
cancelPending();
pending = { kind, id, body, label, source: node, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, pointerId: e.pointerId, touch: e.pointerType !== 'mouse', armed: e.pointerType === 'mouse', timer: null };
if (pending.touch) {
const p = pending;
p.timer = setTimeout(() => { if (pending === p) { p.armed = true; beginDrag(p); try { node.setPointerCapture(p.pointerId); } catch { /* gone */ } } }, TOUCH_HOLD_MS);
}
});
}
// Document-level move/up so the drag survives leaving the card it started on.
function onPointerMove(e) {
if (drag) { e.preventDefault(); moveDrag(e.clientX, e.clientY); return; }
if (!pending || e.pointerId !== pending.pointerId) return;
const dist = Math.hypot(e.clientX - pending.sx, e.clientY - pending.sy);
if (pending.touch) { if (dist > 10) cancelPending(); return; } // moved before the hold: it's a scroll
if (dist >= DRAG_MOVE_PX) { const p = pending; pending = null; p.x = e.clientX; p.y = e.clientY; beginDrag(p); }
}
function onPointerUp(e) {
if (drag) { e.preventDefault(); suppressClick = true; setTimeout(() => { suppressClick = false; }, 0); endDrag(e.type === 'pointerup'); }
cancelPending();
}
let suppressClick = false; // the click that follows a drop must not also select / place
// While a finger drag is live, stop the page from scrolling under it.
function onTouchMove(e) { if (drag) e.preventDefault(); }
document.addEventListener('pointermove', onPointerMove, { passive: false });
document.addEventListener('pointerup', onPointerUp);
document.addEventListener('pointercancel', onPointerUp);
document.addEventListener('touchmove', onTouchMove, { passive: false });
function stopDragAutoScroll() { cancelPending(); endDrag(false); }
/* ---------- confetti (always animates) ---------- */
function makeParticle(w, h, intensity, seedY) {
@@ -79,11 +161,12 @@ export default function create({ root, moduleApi }) {
function stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } }
function render() {
if (drag) { renderHeld = true; return; }
stopConfetti();
stopParallax();
root.innerHTML = '';
if (choirBtn) choirBtn.classList.toggle('active', !!(state && state.choir));
if (editing) { renderEditor(); return; }
if (editing) { renderEditor(); return; } // replaces its own content, after noting scroll + focus
root.innerHTML = '';
const board = el('div', 'bl-board');
if (!state) { board.classList.add('bl-message'); board.textContent = 'Connecting…'; root.appendChild(board); return; }
@@ -158,13 +241,16 @@ export default function create({ root, moduleApi }) {
}
/* ---------- editor ---------- */
let libFilter = ''; // the photo search box, kept across re-renders
function renderEditor() {
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is just the
// board on top and the picture picker (library) below it.
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is two panes
// that scroll on their own: the spots pinned on top, the photos below — so a photo found far
// down the list never has to be carried back up to its spot. Both panes keep their scroll
// position (and the search box its focus) when a board update redraws the editor.
const prevBoard = root.querySelector('.bl-board-pane'); const prevLib = root.querySelector('.bl-lib-pane');
const keep = { board: prevBoard ? prevBoard.scrollTop : 0, lib: prevLib ? prevLib.scrollTop : 0, searchFocused: document.activeElement && document.activeElement.classList && document.activeElement.classList.contains('bl-lib-search') };
const wrap = el('div', 'bl-editor bl-editor-nobar');
const body = el('div', 'bl-editor-body');
// Feed the drag auto-scroller the pointer position anywhere over the editor.
body.addEventListener('dragover', (e) => { dragY = e.clientY; });
const body = el('div', 'bl-editor-body bl-editor-split');
// Slots (drop targets)
const boardEl = el('div', 'bl-edit-board');
@@ -176,14 +262,7 @@ export default function create({ root, moduleApi }) {
photo.appendChild(slot.person ? photoNode(slot.person) : el('div', 'bl-edit-drop', '+'));
cell.appendChild(photo);
// An occupied card is a drag SOURCE too — move/swap it to another slot.
if (slot.person) {
cell.draggable = true;
cell.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', 'slot:' + slot.index); e.dataTransfer.effectAllowed = 'move';
dragBody = body; dragScrolling = true; startDragAutoScroll();
});
cell.addEventListener('dragend', stopDragAutoScroll);
}
if (slot.person) makeDraggable(cell, 'slot', String(slot.index), body, slot.person.name);
const nameRow = el('div', 'bl-edit-namerow');
const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…');
nameBtn.addEventListener('click', () => renameSlot(slot.index, slot.person ? slot.person.name : ''));
@@ -191,26 +270,23 @@ export default function create({ root, moduleApi }) {
if (slot.person) { const clr = el('button', 'bl-edit-clear', '×'); clr.title = 'Clear'; clr.addEventListener('click', () => api(`/slot/${slot.index}/clear`, { method: 'POST' })); nameRow.appendChild(clr); }
cell.appendChild(nameRow);
cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`));
// drop
cell.addEventListener('dragover', (e) => { e.preventDefault(); cell.classList.add('bl-drop-over'); });
cell.addEventListener('dragleave', () => cell.classList.remove('bl-drop-over'));
cell.addEventListener('drop', (e) => {
e.preventDefault(); stopDragAutoScroll(); cell.classList.remove('bl-drop-over');
const data = e.dataTransfer.getData('text/plain') || '';
if (data.startsWith('lib:')) assign(slot.index, data.slice(4));
else if (data.startsWith('slot:')) { const from = data.slice(5); if (from !== String(slot.index)) api(`/move?from=${from}&to=${slot.index}`, { method: 'POST' }); }
});
cell.addEventListener('click', (e) => { if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
cell.addEventListener('click', (e) => { if (suppressClick) return; if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
boardEl.appendChild(cell);
}
body.appendChild(boardEl);
const boardPane = el('div', 'bl-pane bl-board-pane');
boardPane.appendChild(boardEl);
body.appendChild(boardPane);
// Library
// Library, with a name search
const libPane = el('div', 'bl-pane bl-lib-pane');
const search = el('input', 'bl-lib-search');
search.type = 'search'; search.placeholder = 'Find a photo…'; search.value = libFilter;
libPane.appendChild(search);
const lib = el('div', 'bl-library');
const list = state?.library || [];
if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.'));
for (const person of list) {
const item = el('div', 'bl-lib-item'); item.draggable = true; item.dataset.id = person.id;
const item = el('div', 'bl-lib-item'); item.dataset.id = person.id;
if (person.id === selectedLibId) item.classList.add('bl-lib-selected');
const ph = el('div', 'bl-lib-photo'); ph.appendChild(photoNode(person, 'bl-lib-img')); item.appendChild(ph);
const nm = el('button', 'bl-lib-name', person.name || 'Name…');
@@ -219,19 +295,27 @@ export default function create({ root, moduleApi }) {
const del = el('button', 'bl-lib-del', '×'); del.title = 'Delete photo';
del.addEventListener('click', (e) => { e.stopPropagation(); if (confirm(`Delete ${person.name || 'this photo'} from the library?`)) api(`/library/${person.id}/delete`, { method: 'POST' }); });
item.appendChild(del);
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', 'lib:' + person.id); e.dataTransfer.effectAllowed = 'copy';
dragBody = body; dragScrolling = true; startDragAutoScroll();
});
item.addEventListener('dragend', stopDragAutoScroll);
item.addEventListener('click', () => { selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
makeDraggable(item, 'lib', person.id, body, person.name);
item.addEventListener('click', () => { if (suppressClick) return; selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
lib.appendChild(item);
}
body.appendChild(lib);
const applyFilter = () => {
const q = libFilter.trim().toLowerCase();
for (const item of lib.querySelectorAll('.bl-lib-item')) {
const nm = item.querySelector('.bl-lib-name');
item.style.display = !q || (nm && nm.textContent.toLowerCase().includes(q)) ? '' : 'none';
}
};
search.addEventListener('input', () => { libFilter = search.value; applyFilter(); });
applyFilter();
libPane.appendChild(lib);
body.appendChild(libPane);
wrap.appendChild(body);
root.innerHTML = '';
root.appendChild(wrap);
boardPane.scrollTop = keep.board; libPane.scrollTop = keep.lib;
if (keep.searchFocused) { search.focus(); try { search.setSelectionRange(search.value.length, search.value.length); } catch { /* type=search */ } }
}
function assign(slotIndex, personId) {
@@ -260,7 +344,7 @@ export default function create({ root, moduleApi }) {
title: 'Sync',
onClick() { moduleApi.setStatus('connecting', 'Syncing with Planning Center…'); api('/sync', { method: 'POST' }); },
});
const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; editBtn.classList.toggle('active', editing); render(); } });
const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; if (editing) libFilter = ''; editBtn.classList.toggle('active', editing); render(); } });
// Columns −/+ (touch-friendly; replaces the gear number field).
const MINUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M5 12h14"/></svg>';
const PLUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>';
@@ -293,6 +377,10 @@ export default function create({ root, moduleApi }) {
stopConfetti();
stopParallax();
stopDragAutoScroll();
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
document.removeEventListener('pointercancel', onPointerUp);
document.removeEventListener('touchmove', onTouchMove);
root.innerHTML = '';
},
};
+133 -11
View File
@@ -117,9 +117,61 @@ function scoreMatch(slotLabel, positionLabel) {
* member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot.
*/
const MIN_SCORE = 60;
/** The mix numbers a label names ("[VOX 5] MIX 8" → {"8"}; "Mix 8, VOX 5" → {"8"}). */
function mixNumbers(value) {
const out = new Set(); const re = /\bmix\s*(\d+)\b/gi; let m;
while ((m = re.exec(String(value || '')))) out.add(m[1]);
return out;
}
/**
* How well a person's Planning Center note names a spot. The booth writes the mic/role into each
* vocalist's and the leader's note every week ("Mix 8, VOX 5", "DP48, Drums"), because those
* change week to week and the position name ("Vocalist") doesn't say which mic. A note that names a
* different mix than the spot is never a match; the role part ("VOX 5") is scored like a position.
* 0 means the note says nothing usable about this spot ("NA", blank, or no overlap).
*/
function noteScore(slotLabel, note) {
const text = String(note || '').trim();
if (!text || /^n\/?a$/i.test(text)) return 0;
const slotMix = mixNumbers(slotLabel); const noteMix = mixNumbers(text);
let mixBonus = 0;
if (noteMix.size && slotMix.size) {
if (![...noteMix].some((n) => slotMix.has(n))) return 0;
mixBonus = 100;
}
const role = text.replace(/\bmix\s*\d+\b/gi, ' ');
const roleScore = scoreMatch(slotRoleText(slotLabel), role);
if (!roleScore && !mixBonus) return 0;
if (noteMix.size && slotMix.size && roleScore === 0 && role.replace(/[^a-z0-9]/gi, '')) {
// The mix agrees but the role contradicts it (e.g. "Mix 8, Keys" on a VOX spot) — trust neither.
const roleTokens = labelTokens(role);
if (roleTokens.length) return 0;
}
return mixBonus + roleScore;
}
function assignMembersToSlots(slots, members) {
const pool = members.slice();
return slots.map((slotLabel) => {
const result = slots.map(() => null);
const take = (chosen) => {
// One spot per person: someone scheduled twice (Leader + Acoustic) must not fill a second spot.
const key = chosen.personId || '';
for (let i = pool.length - 1; i >= 0; i -= 1) if (pool[i] === chosen || (key && pool[i].personId === key)) pool.splice(i, 1);
};
// Pass 1 — people whose note names their spot go exactly there, best-fitting pairs first.
const pairs = [];
slots.forEach((slotLabel, s) => pool.forEach((m) => { const sc = noteScore(slotLabel, m.note); if (sc >= MIN_SCORE) pairs.push({ s, m, sc }); }));
pairs.sort((a, b) => b.sc - a.sc);
for (const { s, m } of pairs) {
if (result[s] || !pool.includes(m)) continue;
result[s] = m; take(m);
}
// Pass 2 — everyone else by Planning Center position, into the spots still open.
return result.map((placed, s) => {
if (placed) return placed;
const slotLabel = slots[s];
const role = slotRoleText(slotLabel);
let bestIndex = -1;
let bestScore = MIN_SCORE - 1;
@@ -128,18 +180,66 @@ function assignMembersToSlots(slots, members) {
if (score > bestScore) { bestScore = score; bestIndex = i; }
}
if (bestIndex < 0) return null;
return pool.splice(bestIndex, 1)[0];
const chosen = pool[bestIndex];
take(chosen);
return chosen;
});
}
/* ── photos by instrument ──────────────────────────────────────────────
* A person can have a photo per instrument, named "Name - Instrument" ("DJ - Drums.jpg",
* "DJ - Vocals.jpg"); a photo with no instrument word is their everyday one. Sync picks the photo
* whose instrument matches the spot the person is in that week. Instruments group into families so
* the usual shorthands agree: Vox/Vocals/Singer, Keys/Piano/Pad/Synth, Drums/Perc, Bass,
* Guitar/Electric/Acoustic/Rhythm, Leader. */
const ROLE_FAMILY = {
vocals: 'vocals', keys: 'keys', piano: 'keys', pad: 'keys', synth: 'keys', organ: 'keys',
drums: 'drums', percussion: 'drums', bass: 'bass',
guitar: 'guitar', electric: 'guitar', acoustic: 'guitar', rhythm: 'guitar', leader: 'leader',
};
/** The instrument families a label names ("[LEADER, VOX 1, ACOUSTIC] MIX 1" → leader, vocals, guitar). */
function roleFamilies(text) {
const out = new Set();
for (const t of normalizeLabel(text).split(' ')) { const f = ROLE_FAMILY[normalizeToken(t)]; if (f) out.add(f); }
return out;
}
/** The instrument families a SPOT is for: its bracketed role, else the PCO position it was filled by. */
function spotFamilies(slotLabel, positionLabel) {
const fromSlot = roleFamilies(slotRoleText(slotLabel));
return fromSlot.size ? fromSlot : roleFamilies(positionLabel || '');
}
/** Split a photo's name into the person and the instruments it shows: "DJ Drums" → "DJ" + {drums}.
* The first word is always the person; instrument words after it are read off, other words
* ("action", "2") are just part of the photo's description. */
function photoParts(libName) {
const words = normalizeLabel(libName).split(' ').filter(Boolean);
const roles = new Set(); const person = [];
words.forEach((w, i) => {
const f = i > 0 ? ROLE_FAMILY[normalizeToken(w)] : null;
if (f) roles.add(f); else if (!roles.size) person.push(w);
});
return { person: person.join(' '), roles };
}
/** The key a remembered photo pick is stored under: per person AND instrument, so DJ's drum pick
* never overrides his vocal photo. A spot naming several ("Leader, Vox 1, Acoustic") files under
* its most specific: leader, then vocals, drums, keys, bass, guitar. No family → everyday pick. */
const ROLE_PRIORITY = ['leader', 'vocals', 'drums', 'keys', 'bass', 'guitar'];
function photoKey(personId, families) {
const f = ROLE_PRIORITY.find((r) => families && families.has(r)) || '';
return f ? `${personId}|${f}` : String(personId || '');
}
/**
* Best library photo for a Planning Center person, matched by name so Sync can use the folder
* photo instead of the PCO headshot. Handles full names, "First L" and first-name-only files,
* preferring the most specific. Returns the entry, or null when there's no confident, UNAMBIGUOUS
* match (e.g. two people share a first name and the photo is first-name-only) — then PCO's photo is used.
* `entries` is [{ ...whatever, name }].
* preferring the most specific. Returns the entry, or null when no library photo is a confident
* match — only then is PCO's headshot used. Several equally good photos ("Mike" and "mike") is
* not a reason to fall back to PCO: the first of them wins (the booth prefers its own photos).
* `entries` is [{ ...whatever, name }]. `want` (optional) is the set of instrument families the
* person is on this week: a photo for that instrument beats their everyday photo, which beats a
* photo of them on something else — but any photo of theirs still beats PCO's headshot.
*/
function bestLibraryMatch(pcoName, entries) {
function bestLibraryMatch(pcoName, entries, want) {
const target = normalizeLabel(pcoName);
if (!target) return null;
const T = target.split(' ').filter(Boolean);
@@ -171,19 +271,39 @@ function bestLibraryMatch(pcoName, entries) {
if (last && last.length >= 3 && T.includes(last)) return 78;
return 0;
};
const wanted = want && want.size ? want : null;
let best = null;
let bestScore = 0;
let tie = false;
for (const e of entries) {
const s = scoreOf(e.name);
if (s > bestScore) { bestScore = s; best = e; tie = false; }
else if (s === bestScore && s > 0) tie = true;
const parts = photoParts(e.name);
let s = scoreOf(parts.roles.size ? parts.person : e.name);
if (s < 60) continue; // not them
if (parts.roles.size && wanted) s += [...parts.roles].some((f) => wanted.has(f)) ? 20 : -20;
if (s > bestScore) { bestScore = s; best = e; }
}
if (bestScore < 60 || tie) return null; // no confident / unambiguous winner → PCO photo
if (!best) return null; // no confident match → PCO photo
return best;
}
/**
* Could this library photo be of this Planning Center person? Used when someone drops a photo onto
* a synced person's spot, to tell "this is their photo" (remember it) from "put someone else here".
* A name match, the same first name ("DJ action" → "DJ McLean"), or first names sharing their
* first three letters (a nickname: "Rose" → "Rosaliz").
*/
function samePerson(pcoName, libName) {
if (bestLibraryMatch(pcoName, [{ name: libName }])) return true;
const a = normalizeLabel(pcoName).split(' ')[0] || ''; const b = photoParts(libName).person.split(' ')[0] || '';
if (a && a === b) return true;
return a.length >= 3 && b.length >= 3 && a.slice(0, 3) === b.slice(0, 3);
}
module.exports = {
samePerson,
roleFamilies,
spotFamilies,
photoParts,
photoKey,
normalizeTeamName,
isAllowedTeamName,
bestLibraryMatch,
@@ -194,5 +314,7 @@ module.exports = {
numbersIn,
scoreMatch,
assignMembersToSlots,
noteScore,
mixNumbers,
MIN_SCORE,
};
+78 -2
View File
@@ -61,7 +61,83 @@ console.log('assignment:', JSON.stringify(out));
assert.strictEqual(pick('Alex Johnson'), 'Alex', 'first-name photo maps to any exact-first-name person');
// exact full name beats a first-name photo
assert.strictEqual(pick('Alex Gaitan', [{ id: 'x', name: 'Alex Gaitan' }, { id: 'a', name: 'Alex' }]), 'Alex Gaitan', 'exact full name wins');
// tie → no match (two identical-scoring photos)
assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null');
// two equally good photos ("Mike" and "mike") → still one of the booth's photos, never PCO's
assert.strictEqual(bestLibraryMatch('Mike Powell', [{ id: '1', name: 'Mike' }, { id: '2', name: 'mike' }]).id, '1', 'tie → first library photo');
assert.strictEqual(bestLibraryMatch('Joram Lopez', lib), null, 'no photo of them → PCO headshot');
console.log('bestLibraryMatch: all assertions passed ✓');
}
// --- one spot per person: someone scheduled twice (Leader + Acoustic) fills one spot only ---
{
const P = (label, name, personId) => ({ label, name, personId });
const slots = ['[LEADER, VOX 1, ACOUSTIC] MIX 1', '[GUITAR, 2] MIX 7'];
const got = assignMembersToSlots(slots, [P('[Leader]', 'Mike', 'mp'), P('Guitar [Acoustic]', 'Mike', 'mp'), P('Guitar [Rhythm]', 'Craig', 'cs')]).map((m) => (m ? m.name : null));
assert.deepStrictEqual(got, ['Mike', 'Craig'], 'Mike once, Craig gets the guitar spot');
console.log('one spot per person: all assertions passed ✓');
}
// --- Planning Center notes place the leader and vocalists (real notes from Oct 25 / Nov 1, 2026) ---
{
const { noteScore } = require('./matching');
const NA_SLOTS = [
'[LEADER, VOX 1, ACOUSTIC] MIX 1', '[VOX 2] MIX 2', '[VOX 3] MIX 3', '[VOX 4] MIX 4', '[GUITAR, LEAD] MIX 5',
'[BASS] MIX 6', '[GUITAR, 2] MIX 7', '[VOX 5] MIX 8', '[PIANO]', '[DRUMS]', '[KEYS 2]',
];
const P = (label, name, personId, note) => ({ label, name, personId, note });
// Listed in the order Planning Center returned them — vocalists alphabetical, not by mic.
const oct25 = [
P('Vocalist', 'DJ', 'dj', 'Mix 3, VOX 3'), P('[Leader]', 'Mike', 'mp', 'Mix 1, VOX 1'), P('Guitar [Lead]', 'Yeremis', 'yc'),
P('Piano/Pad', 'Kevin', 'km'), P('Vocalist', 'Genesis', 'gg', 'Mix 4, VOX 4'), P('Guitar [Rhythm]', 'Craig', 'cs'),
P('Guitar [Acoustic]', 'Mike', 'mp'), P('Vocalist', 'Rosaliz', 'rg', 'Mix 2, VOX 2'), P('Drums', 'Luis', 'ld'),
P('Vocalist', 'Bianca', 'bb', 'Mix 8, VOX 5'), P('Guitar [Bass]', 'Joram', 'jl'),
];
const got = assignMembersToSlots(NA_SLOTS, oct25).map((m) => (m ? m.name : null));
assert.deepStrictEqual(got, ['Mike', 'Rosaliz', 'DJ', 'Genesis', 'Yeremis', 'Joram', 'Craig', 'Bianca', 'Kevin', 'Luis', null], 'Oct 25 by notes');
// Nov 1: leader note in different case, a drum note with no mix, an "NA" note.
const nov1 = [P('Guitar [Acoustic]', 'Mike', 'mp', 'NA'), P('Drums', 'Alex', 'ag', 'DP48, Drums'), P('[Leader]', 'Mike', 'mp', 'Mix 1, Vox 1')];
assert.deepStrictEqual(assignMembersToSlots(NA_SLOTS, nov1).map((m) => (m ? m.name : null)).filter(Boolean), ['Mike', 'Alex'], 'Nov 1: Mike once, Alex on drums');
assert.strictEqual(assignMembersToSlots(NA_SLOTS, nov1)[9].name, 'Alex', 'DP48, Drums → [DRUMS]');
// A note never pulls someone onto a different mix.
assert.strictEqual(noteScore('[VOX 2] MIX 2', 'Mix 8, VOX 5'), 0, 'Mix 8 note must not match MIX 2');
assert.strictEqual(noteScore('[VOX 5] MIX 8', 'Mix 8, VOX 5') > 0, true, 'Mix 8, VOX 5 matches [VOX 5] MIX 8');
assert.strictEqual(noteScore('[VOX 5] MIX 8', 'NA'), 0, 'NA says nothing');
assert.strictEqual(noteScore('[VOX 1] MIX 10', 'Mix 1, VOX 1') , 0, 'Mix 1 must not match MIX 10');
console.log('notes: all assertions passed ✓');
}
// --- samePerson: is a dropped photo of the person already in the spot? ---
{
const { samePerson } = require('./matching');
assert.ok(samePerson('Rosaliz Gaitan', 'Rose'), 'nickname: Rose → Rosaliz');
assert.ok(samePerson('Mike Powell', 'mike'), 'first name, any case');
assert.ok(samePerson('Mike Powell', 'Mike Powell action'), 'full name plus words');
assert.ok(samePerson('DJ McLean', 'DJ action'), 'short first name plus words');
assert.ok(!samePerson('Mike Powell', 'Craig'), 'a different person is a replacement, not their photo');
assert.ok(!samePerson('Al Smith', 'Alex'), 'two-letter names never match by prefix');
console.log('samePerson: all assertions passed ✓');
}
// --- a photo per instrument: "DJ - Drums", "DJ - Vocals", plain "DJ" for everything else ---
{
const { bestLibraryMatch, photoParts, spotFamilies, roleFamilies, photoKey } = require('./matching');
// File names arrive with the dash turned to a space: "DJ - Drums.jpg" → "DJ Drums".
const lib = [{ id: 'e', name: 'DJ' }, { id: 'd', name: 'DJ Drums' }, { id: 'v', name: 'DJ Vocals' }, { id: 'k', name: 'Kevin Piano' }, { id: 'm', name: 'Mike Powell Leader' }];
const pick = (name, slot, pos) => { const e = bestLibraryMatch(name, lib, spotFamilies(slot, pos)); return e ? e.id : null; };
assert.strictEqual(pick('DJ McLean', '[DRUMS]', 'Drums'), 'd', 'on drums → drum photo');
assert.strictEqual(pick('DJ McLean', '[VOX 3] MIX 3', 'Vocalist'), 'v', 'on vocals → vocal photo');
assert.strictEqual(pick('DJ McLean', '[KEYS 2]', 'Keys 2'), 'e', 'on keys, no keys photo → everyday photo');
assert.strictEqual(pick('DJ McLean', 'STAGE MANAGER', 'Stage Manager'), 'e', 'no instrument → everyday photo');
assert.strictEqual(pick('Kevin Michel', '[KEYS 2]', 'Keys 2'), 'k', 'Piano and Keys are one family');
assert.strictEqual(pick('Kevin Michel', '[DRUMS]', 'Drums'), 'k', 'only photo is on piano → still his own photo, not PCO');
assert.strictEqual(pick('Mike Powell', '[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]'), 'm', 'full name + instrument');
assert.strictEqual(pick('Craig Strok', '[DRUMS]', 'Drums'), null, 'nobody else\'s instrument photo is borrowed');
assert.deepStrictEqual([...photoParts('DJ Drums action').roles], ['drums'], 'extra words after the instrument are ignored');
assert.strictEqual(photoParts('Joshua P').roles.size, 0, 'a plain name has no instrument');
assert.strictEqual(photoParts('Keys').person, 'keys', 'the first word is always the person');
assert.deepStrictEqual([...spotFamilies('[BASS] MIX 6', 'Guitar [Bass]')], ['bass'], 'the spot decides, not the position');
assert.deepStrictEqual([...roleFamilies('Guitar [Rhythm]')], ['guitar'], 'rhythm guitar is guitar');
assert.strictEqual(photoKey('dj', new Set(['drums'])), 'dj|drums');
assert.strictEqual(photoKey('dj', new Set()), 'dj');
assert.strictEqual(photoKey('mp', spotFamilies('[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]')), 'mp|leader', 'leader spot files under leader');
console.log('photos by instrument: all assertions passed ✓');
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "band-lineup",
"name": "Band Lineup",
"version": "1.11.0",
"version": "1.13.0",
"proddash": ">=1.4.0",
"description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
"client": "client.js",
+72 -17
View File
@@ -32,7 +32,7 @@ const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch } = require('./matching');
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch, samePerson, spotFamilies, photoParts, photoKey } = require('./matching');
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
const API_BASE = String(process.env.BAND_LINEUP_API_BASE || process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
@@ -219,6 +219,27 @@ module.exports = {
const settings = readJsonFile(settingsPath, {}) || {};
const photoFolder = String(config.photoFolder || settings.photoFolder || '').trim(); // admin field (Drive link or local path)
let choirOn = Boolean(settings.choir); // optional extra "CHOIR" slot, toggled from the tile
// The photo the booth picked for a Planning Center person on an instrument ("<PCO person id>|drums"
// → library id; a bare person id is their everyday pick), learned when a library photo is dropped
// onto that person. Sync uses it before any name matching.
if (!settings.photoFor || typeof settings.photoFor !== 'object') settings.photoFor = {};
function rememberPhoto(key, libId) {
if (!key || !libId || settings.photoFor[key] === libId) return;
settings.photoFor[key] = libId; saveSettings();
}
// Library photo for a scheduled person in a spot whose instruments are `fams`:
// 1. the photo picked for them on this instrument;
// 2. a photo named for this instrument ("DJ - Drums");
// 3. their everyday pick, else their best-named photo (an other-instrument photo of them
// still beats PCO's headshot).
function libraryPhotoFor(personId, name, fams) {
const live = (id) => (id && library[id] && library[id].file ? { id, ...library[id] } : null);
const forRole = live(settings.photoFor[photoKey(personId, fams)]);
if (forRole) return forRole;
const m = bestLibraryMatch(name, Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file })), fams);
if (m && [...photoParts(m.name).roles].some((f) => fams.has(f))) return m;
return live(settings.photoFor[personId]) || (m && m.file ? m : null);
}
function saveSettings() { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } catch (err) { log(`save settings failed: ${err?.message || err}`); } }
const CHOIR_LABEL = 'CHOIR';
@@ -247,7 +268,7 @@ module.exports = {
return {
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
reachable: false, lastError: '', lastFetched: 0, columns,
plan: null,
plan: settings.lastPlan || null, // the last synced plan, so hand placements after a restart stamp right
slots: effectiveSlots().map((label, index) => ({ index, label, person: null })),
library: [], choir: choirOn,
};
@@ -313,7 +334,7 @@ module.exports = {
* left alone, so a folder and hand-uploads can coexist. Returns true if anything changed.
*/
const IMG_RE = /\.(jpe?g|png|webp|gif)$/i;
const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim();
const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim(); // "DJ - Drums.jpg" → "DJ Drums"
function extOf(f) { const m = f.match(IMG_RE); const e = (m ? m[1] : 'jpg').toLowerCase(); return e === 'jpeg' ? 'jpg' : e; }
function scanFolder() {
if (!photoFolder) return false;
@@ -449,11 +470,13 @@ module.exports = {
if (!e) return null;
let name = '', file = '', birthday = '', manual = true, personId = '';
if (e.pco) {
// The photo (folder photo, or PCO headshot) was chosen at Sync time — use it as-is.
// A synced person. Their library photo (if any) is read live, so replacing that photo
// updates the board; otherwise the photo chosen at Sync time (a PCO headshot).
manual = false; personId = e.pco.personId || '';
name = String(e.pco.name || '').trim();
file = e.pco.file || '';
birthday = e.pco.birthday || '';
const lib = e.pco.libId ? library[e.pco.libId] : null;
file = (lib && lib.file) || e.pco.file || '';
birthday = e.pco.birthday || (lib && lib.birthday) || '';
} else if (e.personId) {
const lib = library[e.personId];
if (!lib) return null; // its library photo was deleted → slot is empty
@@ -493,7 +516,8 @@ module.exports = {
await syncPhotos(); // import the photo source (Drive folder or local path) on every sync
if (pco.configured && serviceTypeId) {
const plan = pickPlan(await listPlans(serviceTypeId));
latest.plan = plan ? { title: plan.title, dates: plan.dates } : null;
latest.plan = plan ? { id: String(plan.id), title: plan.title, dates: plan.dates } : null;
if (plan) { settings.lastPlan = latest.plan; saveSettings(); }
if (!plan) { latest.reachable = true; latest.lastError = 'No plan found for that service type.'; }
else {
@@ -511,6 +535,7 @@ module.exports = {
personId,
name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
label: String(m.attributes?.team_position_name || '').trim(),
note: String(m.attributes?.notes || '').trim(), // "Mix 8, VOX 5" — where the booth wants them this week
photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(),
});
if (personId) seen.add(personId);
@@ -519,15 +544,29 @@ module.exports = {
for (const id of seen) { try { const b = await pco.get(`${PEOPLE}/people/${encodeURIComponent(id)}`, { 'fields[Person]': 'birthdate' }); bdays.set(id, birthdayMmdd(b?.data?.attributes?.birthdate)); } catch { bdays.set(id, ''); } }
for (const m of members) m.birthday = bdays.get(m.personId) || '';
const libEntries = Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file }));
// A library photo dragged by hand onto a spot for this plan, of someone who IS scheduled
// ("Rose" for Rosaliz Gaitan): that's their photo. Remember it, and show them under their
// Planning Center name, kept in that spot.
for (const [i, e] of Object.entries(board)) {
if (!e || !e.personId || e.plan !== String(plan.id) || !library[e.personId]) continue;
const libName = library[e.personId].name;
const who = [...new Map(members.map((m) => [m.personId, m])).values()].filter((m) => m.personId && samePerson(m.name, libName));
if (who.length !== 1) continue; // nobody, or can't tell which — leave it a plain hand placement
const m = who[0];
rememberPhoto(photoKey(m.personId, spotFamilies(effectiveSlots()[Number(i)] || '', m.label)), e.personId);
board[i] = { pco: { name: m.name, file: library[e.personId].file, libId: e.personId, birthday: m.birthday || '', personId: m.personId, position: m.label }, plan: e.plan };
}
// Slots a person is sitting in by hand — don't fill them, and don't place that same person twice.
// Slots a person was placed in by hand FOR THIS PLAN — don't fill them, and don't place that
// same person twice. A hand placement made for an earlier plan (last week's lineup, or one
// from before placements were stamped) is stale: Sync replaces it like any synced slot.
const eff = effectiveSlots();
const planId = String(plan.id);
const openIdx = []; const manualNames = new Set();
for (let i = 0; i < eff.length; i += 1) {
const e = board[String(i)];
if (e && (e.personId || e.name)) {
const nm = e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
if (e && (e.personId || e.name || e.pco) && e.plan === planId) {
const nm = e.pco ? e.pco.name : e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
if (nm) manualNames.add(normName(nm));
} else openIdx.push(i);
}
@@ -537,11 +576,12 @@ module.exports = {
for (let k = 0; k < openIdx.length; k += 1) {
const i = openIdx[k]; const m = assigned[k];
if (m) {
const lib = bestLibraryMatch(m.name, libEntries); // folder photo wins over the PCO headshot
const file = lib && lib.file ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
board[String(i)] = { pco: { name: m.name, file, birthday: m.birthday || '', personId: m.personId } };
} else if (board[String(i)] && board[String(i)].pco) {
delete board[String(i)]; // was synced, no longer on the plan
// The booth's own photos win over the PCO headshot — the one for the instrument they're on.
const lib = libraryPhotoFor(m.personId, m.name, spotFamilies(eff[i], m.label));
const file = lib ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
board[String(i)] = { pco: { name: m.name, file, libId: lib ? lib.id : '', birthday: m.birthday || '', personId: m.personId, position: m.label } };
} else if (board[String(i)]) {
delete board[String(i)]; // synced or stale, and no longer on the plan
}
}
saveBoard();
@@ -580,6 +620,9 @@ module.exports = {
current = {
streams, getLatest: () => latest, pco, listPlans, photoDir,
library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
planId: () => (latest.plan && latest.plan.id) || '',
rememberPhoto,
slotLabel: (i) => effectiveSlots()[Number(i)] || '',
photoUrlFor, libraryList,
setChoir, choirOn: () => choirOn,
};
@@ -670,6 +713,10 @@ module.exports = {
const from = String(p.get('from') || ''); const to = String(p.get('to') || '');
if (!/^\d+$/.test(from) || !/^\d+$/.test(to) || from === to) return json(res, 400, { error: 'Bad move.' });
const a = current.board[from]; const b = current.board[to];
// Moving a spot is a hand placement for this plan: Sync must not move it back.
const plan = current.planId();
if (a) a.plan = plan;
if (b) b.plan = plan;
if (a === undefined) delete current.board[to]; else current.board[to] = a;
if (b === undefined) delete current.board[from]; else current.board[from] = b;
current.saveBoard(); current.rebuildLocal();
@@ -684,7 +731,14 @@ module.exports = {
const personId = String(q.get('personId') || '').trim();
if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' });
if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' });
if (personId) current.board[slot] = { personId, name: '' };
const here = current.board[slot];
if (personId && here && here.pco && here.pco.personId && samePerson(here.pco.name, current.library[personId].name)) {
// A photo of the person already in this spot: it's their photo now — this week and every
// Sync after. They keep their Planning Center name and their spot.
// Remembered for this instrument only: DJ's pick on drums leaves his vocal photo alone.
current.rememberPhoto(photoKey(here.pco.personId, spotFamilies(current.slotLabel(slot), here.pco.position)), personId);
current.board[slot] = { pco: { ...here.pco, libId: personId, file: current.library[personId].file }, plan: current.planId() };
} else if (personId) current.board[slot] = { personId, name: '', plan: current.planId() };
else delete current.board[slot];
current.saveBoard(); current.rebuildLocal();
json(res, 200, { ok: true });
@@ -702,6 +756,7 @@ module.exports = {
const name = String(q.get('name') || '').trim();
const existing = current.board[index] || { personId: '', name: '' };
existing.name = name;
existing.plan = current.planId();
if (!existing.personId && !name) delete current.board[index];
else current.board[index] = existing;
current.saveBoard(); current.rebuildLocal();
+31
View File
@@ -230,3 +230,34 @@
background: color-mix(in srgb, var(--bg) 60%, transparent); color: var(--text); cursor: pointer; font-size: 0.85em; line-height: 1;
}
[data-module="band-lineup"] .bl-lib-del:hover { background: var(--danger); color: #fff; }
/* Drag: the floating photo that follows the pointer (appended to <body>, so it scopes itself). */
[data-module="band-lineup"].bl-drag-ghost {
position: fixed; left: 0; top: 0; z-index: 10000; pointer-events: none;
width: 72px; aspect-ratio: 3 / 4; margin: -48px 0 0 -36px; border-radius: 8px; overflow: hidden;
border: 2px solid var(--accent); background: var(--panel, #222); color: var(--text); font-size: 11px;
display: flex; align-items: center; justify-content: center; text-align: center;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); opacity: 0.92;
}
[data-module="band-lineup"].bl-drag-ghost img { width: 100%; height: 100%; object-fit: cover; object-position: center top; }
[data-module="band-lineup"] .bl-lib-item,
[data-module="band-lineup"] .bl-edit-slot { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
[data-module="band-lineup"] .bl-edit-slot img,
[data-module="band-lineup"] .bl-lib-item img { -webkit-user-drag: none; pointer-events: none; }
[data-module="band-lineup"] .bl-dragging { opacity: 0.35; }
[data-module="band-lineup"].bl-is-dragging, [data-module="band-lineup"] .bl-is-dragging { cursor: grabbing; }
[data-module="band-lineup"] .bl-is-dragging .bl-edit-slot { transition: box-shadow 0.08s; }
/* Editor as two panes that scroll on their own: spots pinned on top, photos below. */
[data-module="band-lineup"] .bl-editor-split { display: flex; flex-direction: column; overflow: hidden; padding: 0; }
[data-module="band-lineup"] .bl-pane { overflow: auto; overscroll-behavior: contain; padding: 8px 10px; }
[data-module="band-lineup"] .bl-board-pane { flex: 0 0 auto; max-height: 55%; border-bottom: 2px solid var(--border); }
[data-module="band-lineup"] .bl-lib-pane { flex: 1 1 0; min-height: 30%; }
[data-module="band-lineup"] .bl-lib-search {
display: block; width: 100%; box-sizing: border-box; margin: 0 0 8px; padding: 6px 10px;
border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; font-size: 0.85em;
}
[data-module="band-lineup"] .bl-lib-search:focus { outline: none; border-color: var(--accent); }
[data-module="band-lineup"] .bl-board-pane .bl-edit-board { margin: 0; }
[data-module="band-lineup"] .bl-lib-pane .bl-library { border-top: 0; padding-top: 0; }
[data-module="band-lineup"] .bl-board-pane .bl-edit-photo { aspect-ratio: 1 / 1; }
+222
View File
@@ -0,0 +1,222 @@
/* Overflow Tap — the tile.
*
* One fact matters across a booth: which keyword Tap is on right now. That
* is the big line. Under it, the destination it maps to and the groups it
* moved; a small line says whether the slide on screen carries a keyword at
* all; and the last four switches sit at the bottom with their times, so a
* tech can see the service's trail. Whether the last switch landed is the
* corner dot's to say — red when Overflow refused it and the module's own
* retries did not fix that — never a line of text in the tile.
*
* Everything comes from the server's `state` stream. The tile never talks to
* ProPresenter or Overflow itself.
*/
const HISTORY_SHOWN = 4;
const ICON_REFRESH = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9"/><path d="M13.5 2.5v3h-3"/></svg>';
export default function create({ root, moduleApi }) {
let state = null;
let stream = null;
let refreshBtn = null;
let streamDown = false; // the shared ProdDash stream dropped; cleared by the next state
const settings = () => moduleApi.instanceSettings || {};
const on = (key) => settings()[key] !== false;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined && text !== null) node.textContent = text;
return node;
}
function clock(ms) {
if (!ms) return '';
try {
return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch {
return '';
}
}
/** The one-line reason the tile cannot do its job, or '' when it can. */
function blocker() {
if (!state) return 'Waiting for ProdDash';
const pp = state.propresenter || {};
const of = state.overflow || {};
if (!pp.configured) return 'No ProPresenter host — set one in /admin';
if (!of.configured) return 'No Overflow login — add it in /admin';
if (of.reachable && !(state.keywords || []).length) return 'No keyword mappings in the Overflow Dashboard yet';
return '';
}
function render() {
root.textContent = '';
const box = el('div', 'ot-root');
root.append(box);
const stop = blocker();
if (stop) {
box.append(el('div', 'ot-empty', stop));
return;
}
const pp = state.propresenter || {};
const of = state.overflow || {};
const cur = state.current;
const offline = streamDown || !pp.reachable || !of.reachable;
if (offline) {
box.classList.add('is-offline');
const why = streamDown ? 'ProdDash connection lost'
: !of.reachable ? (of.error || 'Overflow unreachable')
: (pp.error ? `ProPresenter unreachable — ${pp.error}` : 'ProPresenter unreachable');
box.append(el('div', 'ot-offline', why));
}
// The keyword Tap is on, its destination and the groups moved. No status
// words here: how the switch went is the corner dot's job (its hover
// carries the reason), and the trail's ✓ / ✕ keeps the record. The
// keyword's colour follows along — dimmed while a switch is in flight,
// red once Overflow has refused it for good.
const main = el('div', 'ot-main');
if (cur) {
main.dataset.tone = cur.pending ? 'muted' : (cur.ok ? 'ok' : 'alert');
main.append(el('div', 'ot-keyword', cur.keyword));
if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination || ''));
if (on('showGroups') && cur.groups && cur.groups.length) main.append(el('div', 'ot-groups', cur.groups.join(' · ')));
} else {
main.dataset.tone = 'muted';
main.append(el('div', 'ot-keyword is-none', '—'));
main.append(el('div', 'ot-dest', 'No keyword sent yet'));
}
box.append(main);
if (on('showSlide')) {
const slide = state.slide || {};
const ann = state.announcement || {};
const slideKeys = Array.isArray(slide.keywords) ? slide.keywords : [];
const annKeys = Array.isArray(ann.keywords) ? ann.keywords : [];
let text;
let cls = 'ot-slide';
if (!pp.reachable) { text = 'ProPresenter: not answering'; cls += ' is-muted'; }
else if (!slide.live && !ann.live) { text = 'Nothing live in ProPresenter'; cls += ' is-muted'; }
else if (slideKeys.length || annKeys.length) {
const parts = [];
if (slideKeys.length) parts.push(`This slide: ${slideKeys.join(' + ')}`);
if (annKeys.length) parts.push(`Announcement: ${annKeys.join(' + ')}`);
text = parts.join(' · ');
cls += ' is-hit';
} else if (slide.live) { text = slide.hasNotes ? 'This slide: no keyword' : 'This slide: no notes'; cls += ' is-muted'; }
else { text = 'Announcement: no keyword'; cls += ' is-muted'; }
box.append(el('div', cls, text));
}
if (on('showHistory')) {
const rows = (state.history || []).slice(0, HISTORY_SHOWN);
const list = el('ol', 'ot-history');
if (!rows.length) {
list.append(el('li', 'ot-h-empty', cur ? 'No earlier switches' : 'No switches yet'));
}
for (const h of rows) {
const row = el('li', `ot-h-row${h.ok ? '' : ' is-failed'}`);
row.title = h.ok
? `${h.keyword} → ${h.destination || ''}${h.groups && h.groups.length ? ` on ${h.groups.join(', ')}` : ''}`
: `${h.keyword}: ${h.error || 'failed'}`;
row.append(el('span', 'ot-h-time', clock(h.at)));
row.append(el('span', 'ot-h-kw', h.keyword));
row.append(el('span', 'ot-h-dest', h.destination || ''));
row.append(el('span', 'ot-h-mark', h.ok ? '✓' : '✕'));
list.append(row);
}
box.append(list);
}
}
function applyStatus() {
if (!state) return moduleApi.setStatus('connecting', 'Waiting for ProdDash');
const s = state.status === 'ok' || state.status === 'connecting' || state.status === 'error' ? state.status : 'connecting';
moduleApi.setStatus(s, state.message || '');
}
function handleState(e) {
try {
state = JSON.parse(e.data);
} catch {
return;
}
streamDown = false;
applyStatus();
render();
}
async function backfill() {
try {
const res = await moduleApi.fetch('/state');
if (!res.ok) return;
const body = await res.json();
if (!body || !body.state || !stream) return;
state = body.state;
streamDown = false;
applyStatus();
render();
} catch {
/* the stream will bring the next state */
}
}
async function refresh() {
if (!refreshBtn) return;
refreshBtn.classList.add('active');
try {
const res = await moduleApi.fetch('/refresh', { method: 'POST' });
if (!res.ok) {
let why = `Keyword refresh failed (${res.status})`;
try { why = (await res.json()).error || why; } catch { /* keep the status */ }
moduleApi.setStatus('error', why);
}
} catch (err) {
moduleApi.setStatus('error', `Keyword refresh failed: ${err && err.message ? err.message : err}`);
} finally {
refreshBtn.classList.remove('active');
}
}
return {
start() {
moduleApi.setStatus('connecting', 'Connecting');
refreshBtn = moduleApi.header.addButton({
icon: ICON_REFRESH,
title: 'Refresh keywords',
onClick: refresh,
});
stream = moduleApi.sse('/stream', {
// A fresh connection replays the state; a tile joining a stream the
// page already carries gets only `open`, and the server sends state
// on change alone — so backfill, or a second tile waits for the next
// switch to draw anything.
open() { backfill(); },
error() {
moduleApi.setStatus('error', 'Lost the connection to ProdDash');
streamDown = true;
render();
},
events: { state: handleState },
});
render();
},
stop() {
if (stream) stream.close();
stream = null;
refreshBtn = null;
root.textContent = '';
},
onConfigChange() {
render();
},
};
}
+66
View File
@@ -0,0 +1,66 @@
'use strict';
/**
* Keyword matching — which of Overflow's keywords a slide's notes carry.
*
* Pure functions, no I/O, so keywords.selftest.js can pin every rule down:
*
* cleanNotes(raw) → the notes with ProPresenter's invisible padding removed
* keywordMatches(notes, keyword) → boolean
* matchKeywords(notes, keywords) → every keyword (in the order given) the notes carry
* sameSet(a, b) → the two keyword lists hold the same keywords
*
* The rule is Overflow's own helper's: the notes are split into whitespace-
* separated words, each stripped of surrounding punctuation and lower-cased,
* and a keyword fires when it is one of those words. So `give` fires on
* "give", "Give!", "#give" and "(give)", and not on "forgiveness", "giveaway"
* or "give_now". Phrases work too, bounded the same way on both ends —
* something the helper cannot do, but nothing it would have matched is lost.
*/
const ZERO_WIDTH = /[​-‍⁠]/g;
function cleanNotes(raw) {
return String(raw ?? '').replace(ZERO_WIDTH, '').replace(/\r\n?/g, '\n');
}
function norm(s) {
return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
}
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function keywordMatches(notes, keyword) {
const n = cleanNotes(notes);
const k = norm(keyword);
if (!k || !n.trim()) return false;
const flat = n.toLowerCase().replace(/\s+/g, ' ');
const re = new RegExp(`(?:^|[^\\p{L}\\p{N}_])${escapeRe(k)}(?=$|[^\\p{L}\\p{N}_])`, 'u');
return re.test(flat);
}
/** Every keyword the notes carry, in the order the list gives them, each once. Entries may be strings or objects with a `keyword`. */
function matchKeywords(notes, keywords) {
const out = [];
const seen = new Set();
for (const raw of keywords || []) {
const k = typeof raw === 'string' ? raw : raw && raw.keyword;
const key = norm(k);
if (!key || seen.has(key)) continue;
if (keywordMatches(notes, k)) {
seen.add(key);
out.push(String(k).trim());
}
}
return out;
}
function sameSet(a, b) {
const x = [...(a || [])].map(norm).sort();
const y = [...(b || [])].map(norm).sort();
return x.length === y.length && x.every((v, i) => v === y[i]);
}
module.exports = { cleanNotes, norm, keywordMatches, matchKeywords, sameSet };
+76
View File
@@ -0,0 +1,76 @@
'use strict';
/**
* Self-test for keywords.js — the matching rules, pinned.
*
* node modules/overflow-tap/keywords.selftest.js
*/
const assert = require('node:assert/strict');
const { keywordMatches, matchKeywords, sameSet, cleanNotes } = require('./keywords');
let n = 0;
function test(name, fn) {
n += 1;
try {
fn();
console.log(`ok ${name}`);
} catch (err) {
console.log(`FAIL ${name}\n ${err.message}`);
process.exitCode = 1;
}
}
test('a keyword fires as a word of the notes, whatever the case', () => {
assert.equal(keywordMatches('give', 'give'), true);
assert.equal(keywordMatches('Give', 'give'), true);
assert.equal(keywordMatches('please GIVE today', 'give'), true);
});
test('surrounding punctuation is ignored, as the helper strips it', () => {
assert.equal(keywordMatches('give!', 'give'), true);
assert.equal(keywordMatches('#give now', 'give'), true);
assert.equal(keywordMatches('go to (give) now', 'give'), true);
assert.equal(keywordMatches('"give"', 'give'), true);
assert.equal(keywordMatches('give.', 'give'), true);
});
test('a keyword inside another word does not fire', () => {
assert.equal(keywordMatches('a sermon on forgiveness', 'give'), false);
assert.equal(keywordMatches('giveaway', 'give'), false);
assert.equal(keywordMatches('give_now', 'give'), false);
});
test('a phrase keyword is bounded the same way', () => {
assert.equal(keywordMatches('Please fill out your Next Steps card', 'next steps'), true);
assert.equal(keywordMatches('nextsteps', 'next steps'), false);
});
test('regex characters in a keyword are literal', () => {
assert.equal(keywordMatches('give.now', 'give.now'), true);
assert.equal(keywordMatches('giveXnow', 'give.now'), false);
});
test('empty notes never match; zero-width padding is ignored', () => {
assert.equal(keywordMatches('', 'give'), false);
assert.equal(keywordMatches('​', 'give'), false);
assert.equal(cleanNotes('​give'), 'give');
assert.equal(keywordMatches('​give', 'give'), true);
});
test('matchKeywords returns every hit, in list order, once each', () => {
const list = ['connect', 'give', 'prayer', { keyword: 'Events' }];
assert.deepEqual(matchKeywords('give and connect, then events', list), ['connect', 'give', 'Events']);
assert.deepEqual(matchKeywords('just worship', list), []);
assert.deepEqual(matchKeywords('give', []), []);
assert.deepEqual(matchKeywords('give give', ['give', 'GIVE']), ['give']);
});
test('sameSet compares keyword lists regardless of order and case', () => {
assert.equal(sameSet(['give', 'connect'], ['Connect', 'give']), true);
assert.equal(sameSet(['give'], ['give', 'connect']), false);
assert.equal(sameSet([], []), true);
assert.equal(sameSet(['give'], []), false);
});
console.log(`${n} tests, exit ${process.exitCode || 0}`);
+112
View File
@@ -0,0 +1,112 @@
{
"id": "overflow-tap",
"name": "Overflow Tap",
"version": "1.0.0",
"proddash": ">=1.11.0",
"description": "Switches Overflow Tap destinations from keywords in ProPresenter slide notes",
"permissions": [
{
"kind": "local-network",
"reason": "Reaching ProPresenter on this network"
}
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": {
"w": 2,
"h": 2
},
"defaultSize": {
"w": 4,
"h": 3
},
"configGroups": {
"ProPresenter": {
"help": "The ProPresenter whose slide notes carry the keywords. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Only the live slide's notes (and the announcement layer's) are read — nothing on the stage screens changes."
},
"Overflow": {
"collapsed": "whenSet",
"help": "An Overflow Dashboard login with Tap access — what Overflow's own ProPresenter helper app signs in with. Google sign-in does not work here: the account needs a password (Forgot Your Password? on the Dashboard sets one). The keywords, their destinations and their Tap groups are managed in the Dashboard under Integrations › ProPresenter › Keyword Mappings and read from there; after changing them, press the tile's refresh button or wait a few minutes."
},
"Alerts": {
"toggle": "alertEnabled",
"collapsed": true,
"help": "Flash the dashboards listening on this channel when a switch fails — Overflow unreachable or refusing the change — so someone can fix Tap by hand before the moment passes."
}
},
"configSchema": {
"propresenter": {
"type": "endpoint",
"label": "ProPresenter",
"group": "ProPresenter",
"default": {
"host": "",
"port": 1025
}
},
"password": {
"type": "password",
"label": "API password",
"group": "ProPresenter",
"default": "",
"help": "Only if a password is set in ProPresenter's network preferences."
},
"overflowEmail": {
"type": "string",
"label": "Email",
"group": "Overflow",
"default": ""
},
"overflowPassword": {
"type": "password",
"label": "Password",
"group": "Overflow",
"default": ""
},
"nonprofitId": {
"type": "string",
"label": "Nonprofit ID",
"group": "Overflow",
"default": "",
"help": "Leave blank: a church login carries its own. Only an @overflow.co staff login needs one."
},
"alertEnabled": {
"type": "switch",
"label": "Alert when a switch fails",
"group": "Alerts",
"default": false
},
"alertChannel": {
"type": "number",
"label": "Channel",
"group": "Alerts",
"default": 1,
"min": 1,
"max": 6,
"help": "1–6; each dashboard chooses the channels it listens to in its Layouts menu."
}
},
"instanceSchema": {
"showDestination": {
"type": "switch",
"label": "Destination",
"default": true
},
"showGroups": {
"type": "switch",
"label": "Groups",
"default": true
},
"showSlide": {
"type": "switch",
"label": "Slide match",
"default": true
},
"showHistory": {
"type": "switch",
"label": "History",
"default": true
}
}
}
+174
View File
@@ -0,0 +1,174 @@
'use strict';
/**
* Overflow Dashboard API — the calls Overflow's own "ProPresenter Integration"
* helper app (1.2.3) makes, read from its bundle on 2026-10-11. None of this is
* in Overflow's public API reference (which documents only reads for Tap): it
* is the Dashboard's own backend, used with a Dashboard login — the same
* email and password the helper asks for. Google sign-in does not work here;
* the account needs a password (Overflow's article says the same).
*
* POST /nonprofit-dashboard/authentication/login/jwt
* { email, password } → { token } a JWT; its payload carries
* email, nonprofitId, permissions [{ action, subject }] (subject TAP
* or all is required), nonprofits [...], exp
* GET /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/keywords
* → [ { keyword, destinationName, tapGroups: [{ id, tapGroupName }], … } ]
* the Dashboard's Integrations › ProPresenter › Keyword Mappings
* POST /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/keywords?syncNonprofit=true
* { keywords: ["give"] } → the same list, updated THE SWITCH:
* Overflow points the keyword's groups at its destination
* GET /nonprofit-dashboard/propresenter/nonprofits?search=&limit=
* POST /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/select
* only for an @overflow.co staff login, which has no nonprofit of its
* own and must pick one first
*
* Bearer auth on everything after login. A 401 means the session is gone —
* sign in again. The helper's own wording for a refused login is kept here
* (401 invalid email or password, 429 too many attempts, 403 locked).
*
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the client at
* tools/overflow-tap-mock.js.
*/
const DEFAULT_API_BASE = 'https://server.overflow.co';
const API_BASE = String(process.env.OVERFLOW_TAP_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
const DASH = '/nonprofit-dashboard';
const TIMEOUT_MS = 10000;
const TRIGGER_TIMEOUT_MS = 15000;
class OverflowError extends Error {
constructor(message, { status = 0, retryable = false, auth = false } = {}) {
super(message);
this.name = 'OverflowError';
this.status = status;
this.retryable = retryable;
this.auth = auth; // the session was refused: sign in again before retrying
}
}
/** The JWT's payload, or null when the token is not one. No signature check — we only read our own session. */
function decodeJwt(token) {
const parts = String(token || '').split('.');
if (parts.length !== 3) return null;
try {
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
} catch {
return null;
}
}
/** What the module needs to know about a session, from its token. */
function describeSession(token) {
const p = decodeJwt(token) || {};
const email = String(p.email || '');
const perms = Array.isArray(p.permissions) ? p.permissions : [];
const nonprofits = Array.isArray(p.nonprofits)
? p.nonprofits.map((n) => ({ id: String((n && n.id) || ''), name: String((n && n.name) || '') })).filter((n) => n.id)
: [];
return {
email,
staff: email.toLowerCase().endsWith('@overflow.co'),
nonprofitId: p.nonprofitId ? String(p.nonprofitId) : '',
nonprofits,
hasTap: perms.some((x) => x && (x.subject === 'TAP' || x.subject === 'all')),
expiresAt: Number.isFinite(Number(p.exp)) ? Number(p.exp) * 1000 : 0,
};
}
function createOverflowClient({ fetchImpl = globalThis.fetch } = {}) {
async function request(method, path, { query, body, token, timeout = TIMEOUT_MS } = {}) {
const url = new URL(API_BASE + path);
for (const [k, v] of Object.entries(query || {})) {
if (v === undefined || v === null || v === '') continue;
url.searchParams.set(k, String(v));
}
const headers = { Accept: 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
if (body !== undefined) headers['Content-Type'] = 'application/json';
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeout);
let res;
try {
res = await fetchImpl(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: ctl.signal });
} catch (err) {
const aborted = err && err.name === 'AbortError';
const cause = err && err.cause && (err.cause.code || err.cause.message);
throw new OverflowError(
aborted ? `Overflow did not answer within ${timeout / 1000} s` : `Overflow unreachable: ${err.message}${cause ? ` (${cause})` : ''}`,
{ retryable: true },
);
} finally {
clearTimeout(timer);
}
const text = await res.text();
let data = null;
if (text) {
try { data = JSON.parse(text); } catch { data = null; }
}
if (!res.ok) {
const detail = data && (data.message || data.error || data.detail);
const what = Array.isArray(detail) ? detail.join('; ') : (detail ? String(detail) : '');
throw new OverflowError(what ? `Overflow answered ${res.status}: ${what}` : `Overflow answered ${res.status} ${res.statusText}`.trim(), {
status: res.status,
auth: res.status === 401,
retryable: res.status === 429 || res.status >= 500,
});
}
return data;
}
return {
base: API_BASE,
/** Sign in with a Dashboard login; resolves to the JWT. */
async login(email, password) {
try {
const data = await request('POST', `${DASH}/authentication/login/jwt`, { body: { email, password } });
const token = data && data.token;
if (!token || typeof token !== 'string') throw new OverflowError('Overflow sign-in answered without a token');
return token;
} catch (err) {
if (!(err instanceof OverflowError) || !err.status) throw err;
if (err.status === 401) throw new OverflowError('Overflow refused the sign-in: invalid email or password', { status: 401 });
if (err.status === 429) throw new OverflowError('Overflow refused the sign-in: too many attempts — wait a while', { status: 429, retryable: true });
if (err.status === 403) throw new OverflowError('Overflow refused the sign-in: account locked', { status: 403 });
throw err;
}
},
/** The Keyword Mappings for a nonprofit, as the Dashboard holds them. */
async listKeywords(token, nonprofitId) {
const data = await request('GET', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/keywords`, { token });
return Array.isArray(data) ? data : [];
},
/** Tell Overflow these keywords are on screen; it moves their groups. Resolves to the updated list when one comes back. */
async trigger(token, nonprofitId, keywords) {
const data = await request('POST', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/keywords`, {
query: { syncNonprofit: 'true' },
body: { keywords },
token,
timeout: TRIGGER_TIMEOUT_MS,
});
return Array.isArray(data) ? data : null;
},
/** Staff logins only: the nonprofits this login may act for. */
async searchNonprofits(token, search = '', limit = 10) {
const data = await request('GET', `${DASH}/propresenter/nonprofits`, { token, query: { search, limit } });
const list = (data && data.data && data.data.nonprofits) || (data && data.nonprofits) || (data && data.data) || data;
return Array.isArray(list)
? list.map((n) => ({ id: String((n && n.id) || ''), name: String((n && n.name) || ''), slug: String((n && n.slug) || '') }))
: [];
},
/** Staff logins only: bind the session to one nonprofit before reading its keywords. */
selectNonprofit(token, nonprofitId) {
return request('POST', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/select`, { token, body: {} });
},
};
}
module.exports = { createOverflowClient, OverflowError, decodeJwt, describeSession, API_BASE, DEFAULT_API_BASE };
+678
View File
@@ -0,0 +1,678 @@
'use strict';
/**
* Overflow Tap — server entry.
*
* Does what Overflow's "ProPresenter Integration" helper app does, inside
* ProdDash: watches the live slide in ProPresenter (the presentation layer
* and the announcement layer), and when its notes carry one of the keywords
* mapped in the Overflow Dashboard, tells Overflow — which points that
* keyword's Tap groups at its destination, so what people reach when they
* tap a disc follows what is on screen. A slide with no keyword changes
* nothing; the last destination stays until the next keyworded slide.
*
* A switch Overflow refuses is retried three times, ten seconds apart, with
* the tile's dot left alone meanwhile; only when those are spent does the
* dot go red (and the alert fire, if switched on) — the booth hears about a
* problem the module could not fix itself, not about every hiccup.
*
* GET /state → the view-model every tile draws
* GET /stream → SSE, `state` events on every change (heartbeated)
* POST /refresh → re-read the keyword mappings from the Dashboard now
* GET /keywords → the mappings as read, for a look while setting up
*
* ProPresenter is polled (GET /v1/status/slide for the presentation layer,
* GET /v1/announcement/slide_index for announcements, once a second each —
* small requests, nothing heavier) because its chunked update stream is the
* thing that goes quiet without saying so. Overflow is the Dashboard's own
* API, signed in with a Dashboard login — the calls, and where they come
* from, are at the top of overflow.js. Keyword mappings are read on start,
* every few minutes after that, on /refresh, and from every trigger's answer.
*
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the module at
* tools/overflow-tap-mock.js, which also plays a ProPresenter on :1602.
*/
const crypto = require('crypto');
const { createOverflowClient, OverflowError, describeSession } = require('./overflow');
const { matchKeywords, sameSet, cleanNotes, norm } = require('./keywords');
const POLL_MS = 1000; // ProPresenter, while it answers
const RETRY_MS = 3000; // ProPresenter, while it does not
const PP_TIMEOUT_MS = 4000;
const HEARTBEAT_MS = 15000;
const KEYWORDS_REFRESH_MS = 5 * 60 * 1000;
const OVERFLOW_RETRY_MS = 30 * 1000;
const LOGIN_MIN_GAP_MS = 20 * 1000; // a refused sign-in is not retried sooner (429 = "too many attempts")
const SESSION_MARGIN_MS = 60 * 1000; // sign in again this long before the token expires
const SWITCH_RETRY_MS = 10 * 1000;
const SWITCH_RETRIES = 3;
const HISTORY_MAX = 8; // the tile shows four; a few more for the hover
const ALERT_SECONDS = 30;
/** Set by init(), read by routes() — both are rebuilt together on a config save. */
let current = null;
/**
* The signed-in session, kept across re-inits for the same login: an admin
* saving config five times in a minute must not become five sign-ins
* (Overflow refuses that with 429). Keyed on a hash of the login, so a
* changed email or password starts fresh; the token itself never leaves the
* server process.
*/
let sessionCache = null; // { key, token, expiresAt, email, nonprofitId }
function json(res, code, body) {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
}
/** One line for a log or a tile; a fetch failure names its cause (ECONNREFUSED, ENOTFOUND…). */
function describe(err) {
if (err instanceof OverflowError) return err.message;
if (err && err.name === 'AbortError') return 'timed out';
const msg = err && err.message ? err.message : String(err);
const cause = err && err.cause && (err.cause.code || err.cause.message);
return cause && !msg.includes(String(cause)) ? `${msg} (${cause})` : msg;
}
function unique(list) {
const seen = new Set();
const out = [];
for (const v of list) {
const k = norm(v);
if (!k || seen.has(k)) continue;
seen.add(k);
out.push(v);
}
return out;
}
/** The destination and group names Overflow attached to a keyword mapping, whatever the field names turn out to be. */
function destinationOf(entry) {
return String((entry && (entry.destinationName || (entry.destination && entry.destination.name))) || '');
}
function groupsOf(entry) {
const list = entry && Array.isArray(entry.tapGroups) ? entry.tapGroups : [];
return list.map((g) => String((g && (g.tapGroupName || g.name || g.id)) || '')).filter(Boolean);
}
module.exports = {
init({ config, log, shell }) {
/* ── config ────────────────────────────────────────────────────── */
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
const host = String(ep.host || '').trim();
const port = Number(ep.port) || 1025;
const ppPassword = String(config.password || '');
const email = String(config.overflowEmail || '').trim();
const ofPassword = String(config.overflowPassword || '');
const staffNonprofit = String(config.nonprofitId || '').trim();
const configured = Boolean(email && ofPassword);
const alertEnabled = Boolean(config.alertEnabled);
const alertChannel = Math.min(6, Math.max(1, Math.round(Number(config.alertChannel) || 1)));
const sessionKey = crypto.createHash('sha256').update(`${email}\n${ofPassword}`).digest('hex');
const overflow = createOverflowClient();
/* ── state ─────────────────────────────────────────────────────── */
const streams = new Set();
const timers = new Set();
let stopped = false;
let lastFrame = '';
let keywordCache = new Map(); // norm(keyword) → the Dashboard's entry
let session = null; // { token, expiresAt, email, nonprofitId }
let lastLoginAt = 0;
let lastLoginError = null;
let wanted = null; // the keywords Tap should be on: { keywords, keyword, destination, groups }
let inFlight = false;
let retriesLeft = 0;
let retryTimer = null;
let alertRaised = false;
/**
* Refusals in a row, across keywords. The retry budget is per keyword, but
* a service keeps bringing new keywords: if Overflow refuses every one of
* them, each would start a fresh budget and the dot would never go red.
* So the budget is also spent by refusals as a whole, and a success
* clears it.
*/
let consecutiveFailures = 0;
// Presentation layer: the slide /v1/status/slide reports.
let lastSlideKey = null;
let lastSlide = null;
let lastPresKeywords = [];
// Announcement layer: the slide /v1/announcement/slide_index points at, read from /v1/announcement/active.
let annSupported = true;
let lastAnnKey = null;
let annNotes = '';
let annLive = false;
let lastAnnKeywords = [];
const state = {
status: 'connecting',
message: '',
propresenter: { configured: Boolean(host), reachable: false, error: '', host, port },
overflow: { configured, reachable: false, error: '', keywords: 0, loadedAt: 0 },
keywords: [],
slide: { live: false, keywords: [], hasNotes: false },
announcement: { live: false, keywords: [] },
current: null,
history: [],
updatedAt: 0,
};
/* ── timers that die with the handle ───────────────────────────── */
function later(fn, ms) {
const t = setTimeout(() => {
timers.delete(t);
if (!stopped) fn();
}, ms);
t.unref?.();
timers.add(t);
return t;
}
/* ── health, shared by the admin line and the tiles ────────────── */
function assess(forAdmin = false) {
const pp = state.propresenter;
const of = state.overflow;
if (!pp.configured) return ['error', 'No ProPresenter host configured — set one in /admin'];
if (!of.configured) return ['error', 'Overflow login not set — add the email and password in /admin'];
if (!of.reachable) return ['error', of.error || 'Signing in to Overflow…'];
if (!pp.reachable) return ['error', pp.error ? `ProPresenter unreachable: ${pp.error}` : 'Reaching ProPresenter…'];
if (!state.keywords.length) return ['error', 'No keyword mappings in the Overflow Dashboard (Integrations › ProPresenter)'];
// A refused switch the module is still retrying on its own is amber —
// being worked on; one it has given up on is red — the booth's problem.
const cur = state.current;
if (cur && !cur.ok && !cur.pending && !cur.retrying) {
return ['error', `Tap not switched to ${cur.keyword} — ${cur.error}`];
}
if (cur && !cur.ok && (cur.retrying || consecutiveFailures > 0)) {
return ['connecting', `Tap not switched to ${cur.keyword} yet — retrying (${consecutiveFailures} of ${SWITCH_RETRIES + 1} attempts failed): ${cur.error || 'in flight'}`];
}
const n = state.keywords.length;
const who = forAdmin && session && session.email ? `${session.email}, ` : '';
return ['ok', `Following ${host}:${port} · Overflow: ${who}${n} keyword${n === 1 ? '' : 's'}`];
}
function publish() {
if (stopped) return;
[state.status, state.message] = assess(false);
const { updatedAt, ...rest } = state;
let frame;
try {
frame = JSON.stringify(rest);
} catch {
return;
}
if (frame === lastFrame) return;
lastFrame = frame;
state.updatedAt = Date.now();
const payload = `event: state\ndata: ${JSON.stringify(state)}\n\n`;
for (const res of streams) {
try {
res.write(payload);
} catch {
streams.delete(res);
}
}
}
/* ── the Overflow session ──────────────────────────────────────── */
async function ensureSession() {
const now = Date.now();
if (session && now < session.expiresAt - SESSION_MARGIN_MS) return session;
if (sessionCache && sessionCache.key === sessionKey && now < sessionCache.expiresAt - SESSION_MARGIN_MS) {
session = sessionCache;
return session;
}
if (lastLoginError && now - lastLoginAt < LOGIN_MIN_GAP_MS) throw lastLoginError;
lastLoginAt = now;
let token;
try {
token = await overflow.login(email, ofPassword);
} catch (err) {
lastLoginError = err;
throw err;
}
lastLoginError = null;
const info = describeSession(token);
if (!info.hasTap) throw new OverflowError('This Overflow login has no Tap permission — use one with the Organization Admin or Tap Only role');
let nonprofitId = info.nonprofitId || (info.nonprofits.length === 1 ? info.nonprofits[0].id : '');
if (info.staff) {
if (!staffNonprofit) throw new OverflowError('An @overflow.co login has no nonprofit of its own — enter the Nonprofit ID in /admin');
await overflow.selectNonprofit(token, staffNonprofit);
nonprofitId = staffNonprofit;
} else if (!nonprofitId) {
nonprofitId = staffNonprofit;
}
if (!nonprofitId) throw new OverflowError('This Overflow login carries no nonprofit — enter the Nonprofit ID in /admin');
session = { token, expiresAt: info.expiresAt || now + 12 * 3600 * 1000, email: info.email || email, nonprofitId };
sessionCache = { key: sessionKey, ...session };
log(`signed in to Overflow as ${session.email} (nonprofit ${nonprofitId})`);
return session;
}
function dropSession() {
session = null;
if (sessionCache && sessionCache.key === sessionKey) sessionCache = null;
}
/** Run a signed-in call; when the session is refused, sign in again once and retry. */
async function withSession(fn) {
const s = await ensureSession();
try {
return await fn(s);
} catch (err) {
if (!(err instanceof OverflowError) || !err.auth) throw err;
dropSession();
const again = await ensureSession();
return fn(again);
}
}
/* ── the keyword mappings ──────────────────────────────────────── */
function rebuildKeywordView() {
state.keywords = [...keywordCache.values()].map((e) => ({
keyword: String(e.keyword).trim(),
destination: destinationOf(e),
groups: groupsOf(e),
}));
state.overflow.keywords = state.keywords.length;
}
/** Replace the cache with the Dashboard's list (`merge` folds a partial answer into it instead). */
function applyKeywordList(list, merge = false) {
const next = merge ? new Map(keywordCache) : new Map();
for (const entry of list || []) {
if (!entry || typeof entry !== 'object') continue;
const k = norm(entry.keyword);
if (!k) continue;
if (entry.active === false || entry.enabled === false || entry.isActive === false) {
next.delete(k);
continue;
}
next.set(k, entry);
}
keywordCache = next;
rebuildKeywordView();
}
let loading = null;
let refreshTimer = null;
async function loadKeywords() {
if (stopped || !configured) return;
if (loading) return loading;
loading = (async () => {
try {
const list = await withSession((s) => overflow.listKeywords(s.token, s.nonprofitId));
applyKeywordList(list);
state.overflow.reachable = true;
state.overflow.error = '';
state.overflow.loadedAt = Date.now();
reconsider(); // a slide already up when the mappings arrive takes effect now
} catch (err) {
state.overflow.reachable = false;
state.overflow.error = describe(err);
log(`Overflow: ${state.overflow.error}`);
}
publish();
})();
try {
await loading;
} finally {
loading = null;
}
if (stopped) return;
if (refreshTimer) {
clearTimeout(refreshTimer);
timers.delete(refreshTimer);
}
refreshTimer = later(loadKeywords, state.overflow.reachable ? KEYWORDS_REFRESH_MS : OVERFLOW_RETRY_MS);
}
/* ── the switch ────────────────────────────────────────────────── */
function describeTarget(keys) {
const entries = keys.map((k) => keywordCache.get(norm(k))).filter(Boolean);
return {
keywords: keys,
keyword: keys.join(' + '),
destination: unique(entries.map(destinationOf).filter(Boolean)).join(' + '),
groups: unique(entries.flatMap(groupsOf)),
};
}
function pushHistory(entry) {
const { pending, ...rest } = entry;
state.history.unshift(rest);
if (state.history.length > HISTORY_MAX) state.history.length = HISTORY_MAX;
}
function raiseAlert(keyword) {
if (!alertEnabled || !shell || typeof shell.alert !== 'function') return;
alertRaised = true;
Promise.resolve(shell.alert({ channel: alertChannel, level: 'medium', text: `Tap not switched: ${keyword}`, duration: ALERT_SECONDS }))
.catch((err) => log(`alert refused: ${describe(err)}`));
}
function clearAlert() {
if (!alertRaised || !shell || typeof shell.clearAlert !== 'function') return;
alertRaised = false;
Promise.resolve(shell.clearAlert(alertChannel)).catch(() => {});
}
function requestSwitch(target) {
wanted = target;
retriesLeft = SWITCH_RETRIES;
if (retryTimer) {
clearTimeout(retryTimer);
timers.delete(retryTimer);
retryTimer = null;
}
runSwitch();
}
async function runSwitch() {
if (stopped || inFlight || !wanted) return;
const target = wanted;
const cur = state.current;
const sameTarget = cur && sameSet(cur.keywords, target.keywords);
if (sameTarget && cur.ok) return; // already there
inFlight = true;
let entry;
if (sameTarget) {
entry = cur; // another go at the same switch
} else {
if (cur) pushHistory(cur);
entry = {
keyword: target.keyword,
keywords: target.keywords,
destination: target.destination,
groups: target.groups,
at: Date.now(),
ok: false,
pending: true,
retrying: false, // a retry is scheduled: the module is still trying to fix this itself
error: '',
attempts: 0,
};
state.current = entry;
}
entry.pending = true;
entry.attempts += 1;
publish();
try {
const answer = await withSession((s) => overflow.trigger(s.token, s.nonprofitId, target.keywords));
if (answer) {
applyKeywordList(answer, true);
const fresh = describeTarget(target.keywords);
entry.destination = fresh.destination || entry.destination;
entry.groups = fresh.groups.length ? fresh.groups : entry.groups;
}
entry.ok = true;
entry.error = '';
entry.retrying = false;
entry.at = Date.now();
consecutiveFailures = 0;
clearAlert();
log(`Tap → ${entry.destination || '?'} (${target.keyword})${entry.groups.length ? ` on ${entry.groups.join(', ')}` : ''}`);
} catch (err) {
if (stopped) return;
entry.ok = false;
entry.error = describe(err);
consecutiveFailures += 1;
if (retriesLeft > 0 && consecutiveFailures <= SWITCH_RETRIES) {
// Still the module's problem: try again shortly; the dot goes amber.
retriesLeft -= 1;
entry.retrying = true;
log(`Tap not switched to ${target.keyword}: ${entry.error} — retrying in ${SWITCH_RETRY_MS / 1000} s`);
retryTimer = later(() => {
retryTimer = null;
runSwitch();
}, SWITCH_RETRY_MS);
} else {
// Out of retries: now it is the booth's problem — red dot, and the
// alert if the operator asked for one (raised once per outage, not
// again for every further keyword Overflow refuses).
entry.retrying = false;
log(`Tap not switched to ${target.keyword}: ${entry.error} — giving up after ${consecutiveFailures} refusals`);
if (!alertRaised) raiseAlert(target.keyword);
}
} finally {
entry.pending = false;
inFlight = false;
publish();
if (!stopped && wanted !== target) runSwitch(); // a newer keyword arrived meanwhile
}
}
/* ── what is on screen, and what to do about it ────────────────── */
/**
* The helper's rule, per layer: when the set of keywords the layer's
* slide carries changes to a non-empty set, send it. Returning to a
* keyword after a slide without one sends it again; staying on the same
* keyword across slides sends nothing.
*/
function reconsider() {
const list = [...keywordCache.values()];
const presNotes = lastSlide ? cleanNotes(lastSlide.notes) : '';
const presKeys = lastSlide ? matchKeywords(presNotes, list) : [];
state.slide = { live: Boolean(lastSlide), keywords: presKeys, hasNotes: Boolean(presNotes.trim()) };
const presChanged = !sameSet(presKeys, lastPresKeywords);
if (presChanged) lastPresKeywords = presKeys;
const annKeys = annLive ? matchKeywords(annNotes, list) : [];
state.announcement = { live: annLive, keywords: annKeys };
const annChanged = !sameSet(annKeys, lastAnnKeywords);
if (annChanged) lastAnnKeywords = annKeys;
const batch = [];
if (presChanged && presKeys.length) batch.push(...presKeys);
if (annChanged && annKeys.length) batch.push(...annKeys);
if (batch.length) requestSwitch(describeTarget(unique(batch)));
}
/* ── ProPresenter ──────────────────────────────────────────────── */
function ppUrl(target) {
const u = new URL(`http://${host}:${port}${target}`);
if (ppPassword) u.searchParams.set('password', ppPassword);
return u;
}
async function ppJson(target) {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), PP_TIMEOUT_MS);
try {
const res = await fetch(ppUrl(target), { headers: { Accept: 'application/json' }, signal: ctl.signal });
if (!res.ok) {
const err = new Error(`ProPresenter ${res.status} ${res.statusText}`);
err.status = res.status;
throw err;
}
const text = await res.text();
return text ? JSON.parse(text) : null;
} finally {
clearTimeout(timer);
}
}
/** The presentation layer: /v1/status/slide names the live slide and carries its notes. */
async function pollSlide() {
const data = await ppJson('/v1/status/slide');
const cur = data && data.current && typeof data.current === 'object' ? data.current : null;
const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : '';
if (key === lastSlideKey) return false;
lastSlideKey = key;
lastSlide = cur;
return true;
}
/**
* The announcement layer: its slide index names the document and the
* slide; the document's slides (every group's, in order, disabled ones
* included — that is the index's space) carry the notes. An older
* ProPresenter without this API answers 404 once and is left alone.
*/
async function pollAnnouncement() {
if (!annSupported) return false;
let idx;
try {
idx = await ppJson('/v1/announcement/slide_index');
} catch (err) {
if (err && err.status === 404) {
annSupported = false;
log('this ProPresenter has no announcement layer API — following the presentation layer only');
return false;
}
throw err;
}
const node = idx && idx.announcement_index;
if (!node || !Number.isInteger(node.index)) {
if (lastAnnKey === '') return false;
lastAnnKey = '';
annLive = false;
annNotes = '';
return true;
}
const id = node.presentation_id || {};
const key = `${id.uuid || ''}\u0000${id.name || ''}\u0000${node.index}`;
if (key === lastAnnKey) return false;
const doc = await ppJson('/v1/announcement/active');
const ann = doc && doc.announcement;
const slides = [];
for (const g of (ann && Array.isArray(ann.groups) ? ann.groups : [])) {
for (const s of (g && Array.isArray(g.slides) ? g.slides : [])) slides.push(s);
}
const slide = slides[node.index];
annNotes = slide ? cleanNotes(slide.notes) : '';
annLive = true;
lastAnnKey = key;
return true;
}
async function pollTick() {
if (stopped) return;
let ok = false;
let changed = false;
try {
changed = await pollSlide();
state.propresenter.reachable = true;
state.propresenter.error = '';
ok = true;
try {
if (await pollAnnouncement()) changed = true;
} catch {
/* the presentation poll already said ProPresenter answers; a hiccup here waits for the next tick */
}
} catch (err) {
state.propresenter.reachable = false;
state.propresenter.error = err && err.name === 'AbortError' ? 'timed out' : describe(err);
}
if (changed) reconsider();
publish();
later(pollTick, ok ? POLL_MS : RETRY_MS);
}
/* ── go ────────────────────────────────────────────────────────── */
if (host) {
pollTick();
log(`following ProPresenter at ${host}:${port}`);
} else {
log('no ProPresenter host configured — set one in /admin');
}
if (configured) {
loadKeywords();
log(`Overflow at ${overflow.base}`);
} else {
log('Overflow login not set — add the email and password in /admin');
}
publish();
const heartbeat = setInterval(() => {
for (const res of streams) {
try {
res.write(': ping\n\n');
} catch {
streams.delete(res);
}
}
}, HEARTBEAT_MS);
heartbeat.unref?.();
const handle = {
streams,
configured,
getState: () => state,
refresh: () => loadKeywords(),
};
current = handle;
return {
stop() {
stopped = true;
clearInterval(heartbeat);
for (const t of timers) clearTimeout(t);
timers.clear();
clearAlert();
for (const res of streams) {
try { res.end(); } catch { /* already gone */ }
}
streams.clear();
if (current === handle) current = null;
},
health() {
const [status, message] = assess(true);
return { status, message };
},
};
},
routes() {
return {
'GET /state': (req, res) => {
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
json(res, 200, { state: current.getState() });
},
'GET /stream': (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write('retry: 3000\n\n');
if (!current) return void res.end();
res.write(`event: state\ndata: ${JSON.stringify(current.getState())}\n\n`);
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
current.streams.add(res);
req.on('close', () => current && current.streams.delete(res));
},
'POST /refresh': async (req, res) => {
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
if (!current.configured) return json(res, 400, { error: 'Overflow login is not set.' });
await current.refresh();
const st = current.getState();
if (!st.overflow.reachable) return json(res, 502, { error: st.overflow.error || 'Overflow unreachable.' });
json(res, 200, { ok: true, keywords: st.overflow.keywords });
},
'GET /keywords': (req, res) => {
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
const st = current.getState();
json(res, 200, { keywords: st.keywords, loadedAt: st.overflow.loadedAt, error: st.overflow.error });
},
};
},
};
+186
View File
@@ -0,0 +1,186 @@
/* Overflow Tap — scoped under its tile via [data-module="overflow-tap"] and
styled with the shell's variables only. Sizes hang off the tile (container
units) so the keyword reads from across a booth on a big tile and still
fits a 2×2. */
[data-module="overflow-tap"] {
container-type: size;
}
[data-module="overflow-tap"] .ot-root {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: stretch;
padding: clamp(4px, 4cqmin, 18px);
gap: clamp(2px, 1.8cqmin, 10px);
overflow: hidden;
color: var(--text);
user-select: none;
-webkit-user-select: none;
}
[data-module="overflow-tap"] .ot-offline {
flex-shrink: 0;
font-size: clamp(0.6rem, 4.6cqmin, 0.9rem);
font-weight: 700;
color: var(--danger);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="overflow-tap"] .ot-root.is-offline .ot-main,
[data-module="overflow-tap"] .ot-root.is-offline .ot-slide,
[data-module="overflow-tap"] .ot-root.is-offline .ot-history {
opacity: 0.45;
filter: grayscale(0.6);
}
/* ── the live keyword ───────────────────────────────────────────── */
[data-module="overflow-tap"] .ot-main {
--tone: var(--accent);
flex: 1 1 auto;
min-height: 0;
container-type: size;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
text-align: center;
overflow: hidden;
}
[data-module="overflow-tap"] .ot-main[data-tone="ok"] { --tone: var(--accent); }
[data-module="overflow-tap"] .ot-main[data-tone="warn"] { --tone: var(--warn); }
[data-module="overflow-tap"] .ot-main[data-tone="alert"] { --tone: var(--danger); }
[data-module="overflow-tap"] .ot-main[data-tone="muted"] { --tone: var(--muted); }
[data-module="overflow-tap"] .ot-keyword {
color: var(--tone);
font-size: min(clamp(1rem, 44cqh, 7rem), 18cqw);
font-weight: 750;
line-height: 1.15;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="overflow-tap"] .ot-keyword.is-none { color: var(--muted); }
[data-module="overflow-tap"] .ot-main[data-tone="muted"] .ot-keyword {
animation: ot-pulse 1.2s ease-in-out infinite;
}
@keyframes ot-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
[data-module="overflow-tap"] .ot-dest {
color: var(--text);
font-size: min(clamp(0.7rem, 16cqh, 1.9rem), 8cqw);
font-weight: 600;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="overflow-tap"] .ot-main[data-tone="muted"] .ot-dest { color: var(--muted); font-weight: 500; }
[data-module="overflow-tap"] .ot-groups {
color: var(--muted);
font-size: clamp(0.5rem, 9cqh, 0.95rem);
line-height: 1.3;
flex-shrink: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── this slide ─────────────────────────────────────────────────── */
[data-module="overflow-tap"] .ot-slide {
flex-shrink: 0;
text-align: center;
font-size: clamp(0.55rem, 4.8cqmin, 0.95rem);
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0.25em 0.6em;
border-radius: 999px;
background: color-mix(in srgb, var(--text) 7%, transparent);
align-self: center;
max-width: 100%;
}
[data-module="overflow-tap"] .ot-slide.is-muted { color: var(--muted); }
[data-module="overflow-tap"] .ot-slide.is-hit {
color: var(--accent-text);
background: color-mix(in srgb, var(--accent) 80%, transparent);
font-weight: 650;
}
[data-module="overflow-tap"] .ot-slide.is-warn {
color: var(--warn);
background: color-mix(in srgb, var(--warn) 16%, transparent);
}
/* ── the trail ──────────────────────────────────────────────────── */
[data-module="overflow-tap"] .ot-history {
flex-shrink: 0;
list-style: none;
margin: 0;
padding: 0.3em 0 0;
border-top: 1px solid var(--border);
font-size: clamp(0.55rem, 4.6cqmin, 0.95rem);
display: flex;
flex-direction: column;
gap: 0.1em;
}
[data-module="overflow-tap"] .ot-h-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1.4fr) auto;
align-items: baseline;
gap: 0 0.7em;
color: var(--text);
}
[data-module="overflow-tap"] .ot-h-row.is-failed .ot-h-kw,
[data-module="overflow-tap"] .ot-h-row.is-failed .ot-h-mark { color: var(--danger); }
[data-module="overflow-tap"] .ot-h-time {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
[data-module="overflow-tap"] .ot-h-kw { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
[data-module="overflow-tap"] .ot-h-dest { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
[data-module="overflow-tap"] .ot-h-mark { color: var(--accent); font-weight: 700; }
[data-module="overflow-tap"] .ot-h-empty { color: var(--muted); text-align: center; }
/* On a short tile the trail would crowd the keyword out: keep the
keyword, drop the trail. Inside the keyword block the same rule: when
it is short, the small lines go before the keyword does. */
@container (max-height: 120px) {
[data-module="overflow-tap"] .ot-history { display: none; }
}
@container (max-height: 70px) {
[data-module="overflow-tap"] .ot-groups { display: none; }
}
@container (max-height: 46px) {
[data-module="overflow-tap"] .ot-dest { display: none; }
}
[data-module="overflow-tap"] .ot-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 6%;
color: var(--muted);
text-align: center;
font-size: clamp(0.65rem, 5.5cqmin, 1rem);
}
+14 -1
View File
@@ -1,7 +1,7 @@
{
"id": "prodcom-transcript",
"name": "ProdCom",
"version": "1.4.0",
"version": "1.5.0",
"proddash": ">=1.4.0",
"description": "Live ProdCom transcript; typed text to a ProdCom channel through the Send Message module",
"permissions": [
@@ -38,6 +38,19 @@
"type": "password",
"label": "API key (only if PSK auth is enabled in ProdCom)",
"default": ""
},
"autoStart": {
"type": "switch",
"label": "Start ProdCom when it is offline",
"default": true,
"help": "Only when ProdCom runs on this machine (the server above is 127.0.0.1 or this Mac’s own address). If ProdCom is not running, ProdDash opens it — up to three tries, five seconds apart. macOS only."
},
"apiRestart": {
"type": "switch",
"label": "Restart ProdCom to turn its API on",
"default": true,
"showWhen": "autoStart",
"help": "When ProdCom is running but its Application API is switched off, ProdDash turns the API on and restarts ProdCom to apply it (about ten seconds without intercom). Off: the API is turned on for ProdCom’s next start and the tile says to restart it."
}
},
"instanceSchema": {
+58 -7
View File
@@ -32,9 +32,15 @@
* extractEntries). POST /api/v1/channels/{id}/send with { text } puts typed
* text on a channel (it comes back on the stream with source "typed"). A
* bearer API key is sent when one is configured.
*
* When ProdCom runs on this very Mac and stops answering, supervisor.js opens
* it again — or turns its Application API on and restarts it, the API being
* read only at launch. Every failed connection is reported to it (fail()) and
* every good one (connect()); it does nothing for a ProdCom on another machine.
*/
const http = require('http');
const { createSupervisor } = require('./supervisor');
/** The module that owns the PIN and the unlock tokens — see verifyUnlock(). */
const SENDER_ID = 'send-message';
@@ -60,6 +66,19 @@ function resolveTarget(config) {
return null;
}
/** Codes that mean ProdCom could not be reached at all — not that it answered badly. */
const CONNECTION_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EHOSTUNREACH', 'ENOTFOUND']);
/** The code of an error that is a failed (or timed-out) connection to ProdCom, else ''.
An HTTP status from ProdCom is not one: its API is up, so there is nothing to start. */
function connectionCode(err) {
const code = err && (err.cause?.code || err.code);
if (CONNECTION_CODES.has(code)) return code;
if (code === 'UND_ERR_SOCKET') return 'ECONNRESET'; // fetch's spelling of a connection dropped under it
if (err && (err.name === 'TimeoutError' || err.name === 'AbortError')) return 'ETIMEDOUT';
return '';
}
/** Entries inside whatever ProdCom sent: a bare entry, or one wrapped in data / payload / entry / entries / transcript. */
function extractEntries(obj) {
if (!obj || typeof obj !== 'object') return [];
@@ -181,6 +200,7 @@ module.exports = {
let upstream = null;
let generation = 0; // callbacks from a superseded connection are ignored
let retryTimer = null;
let retryPending = false; // waiting out the backoff, with no connect() under way
let retryMs = RETRY_MIN_MS;
const authHeaders = () => (apiKey ? { authorization: `Bearer ${apiKey}` } : {});
@@ -190,7 +210,11 @@ module.exports = {
headers: { accept: 'application/json', ...authHeaders() },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`HTTP ${res.status} for ${pathname}`);
if (!res.ok) {
const err = new Error(`HTTP ${res.status} for ${pathname}`);
err.httpStatus = res.status; // ProdCom answered, so it is up
throw err;
}
return res.json();
}
@@ -220,6 +244,22 @@ module.exports = {
broadcast('status', status);
}
// Keeps ProdCom itself up when it is on this machine (supervisor.js); a
// no-op everywhere else. Its words reach the tile through setStatus. This
// module is re-init'ed whenever its config is saved, so `config` is current.
const supervisor = createSupervisor({
settings: () => ({ autoStart: config.autoStart !== false, apiRestart: config.apiRestart !== false }),
target: () => ({ host: target ? target.hostname : '', port: target ? Number(target.port) || 80 : 0 }),
log,
status: setStatus,
// ProdCom answers again: connect now rather than when the backoff runs out — unless a connect is already under way.
onUp: () => {
if (stopped || !retryPending) return;
clearTimeout(retryTimer);
connect();
},
});
function trim() {
while (entries.size > ENTRIES_MAX) entries.delete(entries.keys().next().value);
}
@@ -281,22 +321,29 @@ module.exports = {
function scheduleRetry() {
if (stopped) return;
clearTimeout(retryTimer);
retryPending = true;
retryTimer = setTimeout(connect, retryMs);
retryMs = Math.min(RETRY_MAX_MS, retryMs * 2);
}
function fail(gen, why) {
/** `code` is set when the connection itself failed (see connectionCode), not when ProdCom answered badly. */
function fail(gen, why, code = '') {
if (stopped || gen !== generation) return;
generation += 1; // whatever else this connection reports is stale now
try { upstream?.destroy(); } catch { /* already gone */ }
upstream = null;
setStatus('error', `ProdCom unreachable (${why}) — retrying`);
log(`ProdCom ${target.origin}: ${why} — retrying in ${retryMs / 1000} s`);
if (code) supervisor.kick(code);
// While the supervisor is starting ProdCom — or has given up and says why —
// its words are the ones worth reading, not "unreachable" on every retry.
const shown = code || why; // the normalised code where there is one (a timeout reads ETIMEDOUT, not 23)
if (!(code && supervisor.message())) setStatus('error', `ProdCom unreachable (${shown}) — retrying`);
log(`ProdCom ${target.origin}: ${shown} — retrying in ${retryMs / 1000} s`);
scheduleRetry();
}
/** Lists and history first (a fresh snapshot for the tiles), then the live stream. */
async function connect() {
retryPending = false;
if (stopped || !target) return;
const gen = ++generation;
try {
@@ -304,7 +351,8 @@ module.exports = {
await loadHistory();
} catch (err) {
if (gen !== generation || stopped) return;
return fail(gen, err.cause?.code || err.code || err.message);
if (err.httpStatus) supervisor.noteReachable(); // 401, 5xx…: ProdCom is up, whatever it said
return fail(gen, err.cause?.code || err.code || err.message, connectionCode(err));
}
if (gen !== generation || stopped) return;
broadcast('state', snapshot());
@@ -319,9 +367,11 @@ module.exports = {
if (gen !== generation) return void res.destroy();
if (res.statusCode !== 200) {
res.resume();
supervisor.noteReachable(); // ProdCom answered
return fail(gen, `HTTP ${res.statusCode} from the transcript stream`);
}
retryMs = RETRY_MIN_MS;
supervisor.noteReachable();
setStatus('ok', 'Live');
log(`following ${target.origin} — ${channels.length} channels, ${entries.size} entries`);
let buf = '';
@@ -337,9 +387,9 @@ module.exports = {
if (buf.length > 1e6) buf = ''; // a frame that never ends is dropped
});
res.on('end', () => fail(gen, 'stream ended'));
res.on('error', (err) => fail(gen, err.code || err.message));
res.on('error', (err) => fail(gen, err.code || err.message, connectionCode(err)));
});
req.on('error', (err) => fail(gen, err.code || err.message));
req.on('error', (err) => fail(gen, err.code || err.message, connectionCode(err)));
req.end();
upstream = req;
}
@@ -449,6 +499,7 @@ module.exports = {
stop() {
stopped = true;
generation += 1;
supervisor.stop();
clearTimeout(retryTimer);
clearInterval(listsTimer);
clearInterval(heartbeat);
+437
View File
@@ -0,0 +1,437 @@
'use strict';
/**
* ProdCom Transcript — keeping ProdCom itself running.
*
* When ProdCom runs on the same Mac as ProdDash (the booth Mac) and its API
* stops answering, the usual reason is that nobody has opened ProdCom, or that
* its Application API is switched off — ProdCom reads that switch only at
* launch. This watches for that and puts it right: open ProdCom if it is not
* running; if it is running with the API off, turn the API on and restart it.
* Nothing here does anything on another OS, or when ProdCom is on another
* machine — a remote ProdCom is somebody else's to look after.
*
* The module calls kick(code) every time a connection to ProdCom fails and
* noteReachable() when one succeeds; the rest is decided here. A "run" is up to
* three attempts, each: act, wait five seconds, ask the API whether it is up.
* When a run cannot fix it, the supervisor gives up for that outage — kick()
* then returns at once, so the module's reconnect loop can call it every few
* seconds without anything happening — until a connection succeeds again.
*
* Every touch of the machine (is ProdCom running, open it, read/write its
* preference, quit it) and of the network (the ping) is injected, so
* supervisor.test.js drives the decisions with fakes; the real ones are
* defaultOs() and httpProbe() below, all child_process.execFile with an
* argument list — never a shell string — and a timeout.
*/
const http = require('http');
const nodeOs = require('os');
const { execFile } = require('child_process');
const BUNDLE_ID = 'com.prodcom.prodcom';
const PROCESS_NAME = 'ProdCom';
const API_PREF = 'applicationAPIEnabled'; // a UserDefaults bool in the app's own domain
const MAX_ATTEMPTS = 3;
const ATTEMPT_WAIT_MS = 5000; // after acting, before asking whether the API answers
const PROBE_TIMEOUT_MS = 3000;
const QUICK_TOOL_MS = 5000; // pgrep, defaults, pkill
const SLOW_TOOL_MS = 10000; // open
const QUIT_ASK_MS = 5000; // osascript's patience: a ProdCom that will not answer an Apple Event is told with SIGTERM
const QUIT_WAIT_MS = 10000; // for the process to be gone after asking it to quit
const QUIT_POLL_MS = 500;
/* ── the real machine ───────────────────────────────────────────────── */
/** Run a program with an argument list (`execFileImpl` is child_process.execFile, or a fake in tests).
Never rejects: { ok, exitCode, error, stdout, stderr } —
exitCode is the program's own exit status, or null when it never got to exit
normally (not found, timed out, killed). */
function runProgram(execFileImpl, file, args, timeout) {
return new Promise((resolve) => {
execFileImpl(file, args, { timeout, windowsHide: true }, (error, stdout, stderr) => {
resolve({
ok: !error,
exitCode: error ? (typeof error.code === 'number' ? error.code : null) : 0,
error,
stdout: String(stdout || ''),
stderr: String(stderr || ''),
});
});
});
}
/** What went wrong with an exec() result, for a message: how it failed, then the first line the program said about it. */
function why(r) {
let how = 'unknown';
if (r.exitCode !== null) how = `exit ${r.exitCode}`;
else if (r.error) how = r.error.killed ? 'timed out' : (r.error.code || r.error.message);
const said = r.stderr.split('\n').map((l) => l.trim()).find(Boolean);
if (!said) return how;
return `${how}: ${said.length > 120 ? `${said.slice(0, 120)}…` : said}`;
}
/** A sleep the supervisor can cut short: resolves true after `ms`, false at once
when `signal` aborts (its timer is cleared, so nothing is left behind). */
function abortableSleep(ms, signal) {
return new Promise((resolve) => {
if (signal && signal.aborted) return resolve(false);
let timer = null;
const onAbort = () => {
clearTimeout(timer);
resolve(false);
};
timer = setTimeout(() => {
if (signal) signal.removeEventListener('abort', onAbort);
resolve(true);
}, ms);
timer.unref?.();
if (signal) signal.addEventListener('abort', onAbort, { once: true });
});
}
/** `defaults read` prints a bool as 1 / 0; anything else is not an answer. */
function parseApiPref(stdout) {
const s = String(stdout || '').trim().toLowerCase();
if (s === '1' || s === 'true' || s === 'yes') return true;
if (s === '0' || s === 'false' || s === 'no') return false;
return null;
}
/** The address as one canonical spelling, so a configured "::1" matches the interface's. */
function canonicalAddress(a) {
let s = String(a ?? '').trim().toLowerCase();
if (s.startsWith('[') && s.endsWith(']')) s = s.slice(1, -1);
s = s.replace(/%.*$/, ''); // a scope id (fe80::1%en0)
if (s.includes(':')) {
try { s = new URL(`http://[${s}]`).hostname.slice(1, -1); } catch { /* not an address */ }
}
return s;
}
/** Is `host` this machine? `hostname` and `addresses` are this machine's own
(os.hostname(), every address of os.networkInterfaces()) — passed in so this
is a pure function. */
function isLocalHostName(host, hostname, addresses) {
const h = canonicalAddress(host).replace(/\.$/, '');
if (h === '' || h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0.0.0.0') return true;
const bare = (s) => String(s).toLowerCase().replace(/\.$/, '').replace(/\.local$/, '');
if (hostname && bare(hostname) === bare(h)) return true;
return (addresses || []).some((a) => canonicalAddress(a) === h);
}
/** Is anything answering HTTP at host:port? Any response counts, whatever its
status — the API's ping needs no key, and a 401 is still a live server. */
function httpProbe(host, port) {
return new Promise((resolve) => {
let done = false;
const finish = (up) => {
if (done) return;
done = true;
resolve(up);
};
let h = canonicalAddress(host);
if (!h || h === '0.0.0.0') h = '127.0.0.1';
try {
const req = http.get({
host: h,
port,
path: '/api/v1/ping',
agent: false,
timeout: PROBE_TIMEOUT_MS,
headers: { accept: 'application/json' },
}, (res) => {
res.resume();
finish(true);
});
req.on('timeout', () => {
req.destroy();
finish(false);
});
req.on('error', () => finish(false));
} catch {
finish(false);
}
});
}
/**
* The operating-system layer: macOS commands, each run with execFile.
* `deps` is for tests only — { execFile, sleep, now } replace the real
* child_process.execFile, abortableSleep and Date.now.
*/
function defaultOs(deps = {}) {
const exec = (file, args, timeout) => runProgram(deps.execFile || execFile, file, args, timeout);
const wait = deps.sleep || abortableSleep;
const now = deps.now || Date.now;
async function isRunning() {
const r = await exec('/usr/bin/pgrep', ['-x', PROCESS_NAME], QUICK_TOOL_MS);
if (r.ok) return true;
if (r.exitCode === 1) return false; // pgrep's "nothing matched"
throw new Error(`Could not check whether ProdCom is running (${why(r)})`);
}
/** Open ProdCom in the background (-g): a booth Mac must not lose the keyboard from ProPresenter or OBS mid-service. */
async function launch() {
const r = await exec('/usr/bin/open', ['-g', '-b', BUNDLE_ID], SLOW_TOOL_MS);
if (r.ok) return;
if (r.exitCode !== null) throw new Error('ProdCom is not installed on this machine');
throw new Error(`Could not open ProdCom (${why(r)})`);
}
async function readApiPref() {
const r = await exec('/usr/bin/defaults', ['read', BUNDLE_ID, API_PREF], QUICK_TOOL_MS);
if (r.ok) return parseApiPref(r.stdout);
if (r.exitCode !== null) return null; // the key does not exist yet
// Not knowing is not the same as "off": it would restart a ProdCom that is fine.
throw new Error(`Could not read ProdCom's settings (${why(r)})`);
}
async function writeApiPref() {
const r = await exec('/usr/bin/defaults', ['write', BUNDLE_ID, API_PREF, '-bool', 'true'], QUICK_TOOL_MS);
if (!r.ok) throw new Error(`Could not turn on ProdCom's API (${why(r)})`);
}
/** Ask ProdCom to quit; if it will not be asked, terminate it. Resolves when it is gone.
Once `signal` aborts it does nothing more — no SIGTERM, no further look at the process list. */
async function quit(signal) {
const cancelled = () => Boolean(signal && signal.aborted);
if (cancelled()) throw new Error('cancelled');
const asked = await exec('/usr/bin/osascript', ['-e', `tell application id "${BUNDLE_ID}" to quit`], QUIT_ASK_MS);
if (cancelled()) throw new Error('cancelled');
if (!asked.ok) await exec('/usr/bin/pkill', ['-x', '-TERM', PROCESS_NAME], QUICK_TOOL_MS);
const until = now() + QUIT_WAIT_MS;
for (;;) {
if (cancelled()) throw new Error('cancelled');
if (!(await isRunning())) return;
if (now() >= until) throw new Error('ProdCom would not quit');
await wait(QUIT_POLL_MS, signal);
}
}
return {
platform: process.platform,
isLocalHost(host) {
const addresses = [];
for (const list of Object.values(nodeOs.networkInterfaces())) {
for (const a of list || []) addresses.push(a.address);
}
return isLocalHostName(host, nodeOs.hostname(), addresses);
},
isRunning,
launch,
readApiPref,
writeApiPref,
quit,
};
}
/* ── the decisions ──────────────────────────────────────────────────── */
/**
* @param opts.settings () => { autoStart, apiRestart } read live, every time
* @param opts.target () => { host, port } where the module points ProdCom
* @param opts.probe () => Promise<boolean> optional; the API answers (default: GET /api/v1/ping)
* @param opts.os the machine layer (see defaultOs); optional, defaults to the real one
* @param opts.sleep (ms, signal) => Promise optional; the default is cut short by `signal`
* @param opts.log (line) => void
* @param opts.status (state, message) => void the tile's status; state is 'connecting' | 'error'
* @param opts.onUp () => void optional; ProdCom answers again — the module may reconnect now
*/
function createSupervisor(opts) {
const { settings, target, log, status } = opts;
const onUp = opts.onUp || (() => {});
const os = opts.os || defaultOs();
const probe = opts.probe || (() => { const t = target(); return httpProbe(t.host, t.port); });
const sleep = opts.sleep || abortableSleep;
let stopped = false;
let busy = false; // a run is in progress
let gaveUp = false; // this outage is beyond the supervisor until a connection succeeds
let msg = null; // the supervisor's words for the tile
let epoch = 0; // a run belongs to the epoch it started in; a new one ends it
let abort = null; // cuts the running run's sleep (and quit) short
let current = Promise.resolve();
const said = new Set(); // no-op reasons already logged
/** End the run in progress, if any: it unwinds without another word. */
function cancelRun() {
epoch += 1;
if (abort) {
abort.abort();
abort = null;
}
}
/** Nothing to do, and why — logged once per distinct reason, not on every reconnect tick. */
function skip(reason) {
if (!said.has(reason)) {
said.add(reason);
log(reason);
}
return reason;
}
/** Wait `ms`, but never past stop() or the end of the run, whatever `sleep` does with the signal. */
function pause(ms, signal) {
return new Promise((resolve) => {
if (signal.aborted) return resolve();
const done = () => {
signal.removeEventListener('abort', done);
resolve();
};
signal.addEventListener('abort', done, { once: true });
Promise.resolve().then(() => sleep(ms, signal)).then(done, done);
});
}
async function run(myEpoch, signal) {
const live = () => !stopped && epoch === myEpoch;
const say = (state, text) => {
msg = text;
status(state, text);
};
const giveUp = (text) => {
gaveUp = true;
say('error', text);
log(text);
};
let restarted = false; // ProdCom was quit and opened again in this run — once is all it gets
let opened = false; // this run opened ProdCom itself: if it is running now, it may simply still be starting up
let apiOnSilent = false; // the latest look found ProdCom running, API on, port silent, and not of our opening
try {
for (let n = 1; n <= MAX_ATTEMPTS; n += 1) {
const s = settings();
apiOnSilent = false;
// Look afresh every attempt: one sample proves little (a ProdCom that is quitting still shows as running).
const running = await os.isRunning();
if (!live()) return;
if (!running) {
// Not there — never opened, quit by hand, or gone since the restart. A launch, never a second quit.
await os.launch();
if (!live()) return;
opened = true;
say('connecting', `ProdCom is not running — starting it (${n}/${MAX_ATTEMPTS})`);
} else if (!restarted) {
const pref = await os.readApiPref();
if (!live()) return;
if (pref === true) {
// Running with its API on and the port still silent. Slow to start, quitting, or the wrong port —
// one look cannot tell, so wait and look again; only the last attempt blames the port.
apiOnSilent = !opened;
} else {
// The API is off (or the key was never written): switch it on for ProdCom's next start…
await os.writeApiPref();
if (!live()) return;
// …and restart ProdCom only if the setting really holds: that restart cuts its intercom.
const now = await os.readApiPref();
if (!live()) return;
if (now !== true) {
return giveUp(`Could not turn on ProdCom's API — it still reads ${now === false ? 'off' : 'unset'}`);
}
if (!s.apiRestart) {
return giveUp("ProdCom's API is off — restart ProdCom to turn it on");
}
say('connecting', "ProdCom's API is off — turning it on and restarting ProdCom");
await os.quit(signal);
if (!live()) return;
await os.launch();
if (!live()) return;
restarted = true;
}
}
await pause(ATTEMPT_WAIT_MS, signal);
if (!live()) return;
const up = await Promise.resolve().then(probe).catch(() => false);
if (!live()) return;
if (up) {
log(`ProdCom is up after ${n} attempt(s)`);
say('connecting', 'ProdCom is back — reconnecting');
try {
onUp();
} catch (err) {
log(`ProdCom auto-start: reconnect nudge failed (${err && err.message ? err.message : err})`);
}
return;
}
}
giveUp(apiOnSilent
? `ProdCom is running with its API on, but port ${target().port} does not answer — check its API port`
: 'ProdCom would not start — open it by hand');
} catch (err) {
if (!live()) return;
giveUp(err && err.message ? err.message : String(err));
}
}
/** Decide whether a run is called for, and start it: the reason it did not, or null. */
function start(code) {
if (stopped) return 'stopped';
if (!settings().autoStart) return skip('ProdCom auto-start is off');
if (os.platform !== 'darwin') return skip('ProdCom auto-start is macOS only');
const { host } = target();
if (!os.isLocalHost(host)) return skip(`ProdCom at ${host} is not on this machine`);
if (busy) return skip('ProdCom auto-start is already working on it');
if (gaveUp) return skip('ProdCom auto-start has given up until ProdCom answers');
busy = true;
said.clear(); // a new outage says its reasons again
const myEpoch = epoch;
const controller = new AbortController();
abort = controller;
log(`ProdCom did not answer (${code}) — checking whether to start it`);
current = run(myEpoch, controller.signal)
.catch((err) => log(`ProdCom auto-start failed: ${err && err.message ? err.message : err}`))
.then(() => {
busy = false;
if (abort === controller) abort = null;
});
return null;
}
return {
/**
* A connection to ProdCom failed (`code` is the error code). Starts a run
* unless there is nothing to do; answers the reason when it does nothing,
* null when it started one.
*/
kick(code) {
try {
return start(code);
} catch (err) {
// Called from the module's reconnect path: whatever goes wrong here must not stop it reconnecting.
return skip(`ProdCom auto-start could not decide (${err && err.message ? err.message : err})`);
}
},
/** A connection to ProdCom succeeded: this outage is over, the next gets a fresh run. */
noteReachable() {
if (busy) log('ProdCom answered on its own — ending the start-up run');
gaveUp = false;
msg = null;
said.clear();
cancelRun();
},
/** The supervisor's words for the tile while it is working or has given up, else null. */
message() {
return busy || gaveUp ? msg : null;
},
/** Resolves when no run is in progress. */
idle() {
return current;
},
stop() {
stopped = true;
msg = null;
cancelRun();
},
};
}
module.exports = { createSupervisor, defaultOs, isLocalHostName, parseApiPref, abortableSleep };
@@ -0,0 +1,832 @@
'use strict';
/**
* Tests for the ProdCom supervisor (supervisor.js).
*
* node --test modules/prodcom-transcript/supervisor.test.js
*
* Fakes only. The decisions run on a fake machine layer (is ProdCom running,
* open it, its preference, quit it), a fake ping, a fake sleep, log and tile
* status; the real machine layer (defaultOs) runs only on a fake execFile, a
* fake sleep and a fake clock. Nothing here starts, quits or configures a real
* ProdCom, runs a command or waits on a timer. (One test aborts the real
* abortableSleep with an hour on it: the timer is cleared at once.)
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const {
createSupervisor, defaultOs, isLocalHostName, parseApiPref, abortableSleep,
} = require('./supervisor');
const START = (n) => ['connecting', `ProdCom is not running — starting it (${n}/3)`];
const BACK = ['connecting', 'ProdCom is back — reconnecting'];
const NO_START = ['error', 'ProdCom would not start — open it by hand'];
const API_OFF_RESTART = ['connecting', "ProdCom's API is off — turning it on and restarting ProdCom"];
const API_OFF_STAY = ['error', "ProdCom's API is off — restart ProdCom to turn it on"];
const API_PORT = ['error', 'ProdCom is running with its API on, but port 24480 does not answer — check its API port'];
const NOT_STUCK = (what) => ['error', `Could not turn on ProdCom's API — it still reads ${what}`];
/**
* A supervisor on fakes. `calls` is every machine action in order, `sleeps` the
* waits asked for, `statuses` what the tile was told, `logs` what the log got,
* `ups` how often onUp fired. `hold` makes the Nth call of a fake hold until
* the test lets it go — `hold: { 'launch#1': true }`, then `r.release('launch#1')`
* — so a run can be looked at, or ended, in the middle of a machine call.
*/
function rig(o = {}) {
const settings = { autoStart: true, apiRestart: true, ...(o.settings || {}) };
const machine = { running: false, pref: null, local: true, ...(o.machine || {}) };
const calls = [];
const sleeps = [];
const statuses = [];
const logs = [];
const order = []; // statuses and onUp, in the order they happened
const probes = [...(o.probes || [])];
const counts = {};
const gates = {};
let probed = 0;
let ups = 0;
const held = (name) => {
counts[name] = (counts[name] || 0) + 1;
const key = `${name}#${counts[name]}`;
if (!(o.hold && o.hold[key])) return null;
return new Promise((resolve) => { gates[key] = resolve; });
};
const os = {
platform: o.platform || 'darwin',
isLocalHost: (host) => {
calls.push(`isLocalHost:${host}`);
return machine.local;
},
isRunning: async () => {
calls.push('isRunning');
const g = held('isRunning');
if (g) await g;
if (o.runningError) throw new Error(o.runningError);
// `runningSeq` scripts it per attempt (the last answer repeats); otherwise it is whatever `machine.running` says.
if (o.runningSeq) return o.runningSeq.length > 1 ? o.runningSeq.shift() : o.runningSeq[0];
return machine.running;
},
launch: async () => {
calls.push('launch');
const g = held('launch');
if (g) await g;
if (o.launchError) throw new Error(o.launchError);
},
readApiPref: async () => {
calls.push('readApiPref');
const g = held('readApiPref');
if (g) await g;
if (o.readError) throw new Error(o.readError);
return machine.pref;
},
writeApiPref: async () => {
calls.push('writeApiPref');
const g = held('writeApiPref');
if (g) await g;
if (o.writeError) throw new Error(o.writeError);
if (o.writeSticks !== false) machine.pref = true; // the preference now reads on, as `defaults write` makes it
},
quit: async () => {
calls.push('quit');
const g = held('quit');
if (g) await g;
if (o.quitError) throw new Error(o.quitError);
},
};
const sup = createSupervisor({
settings: () => settings,
target: () => ({ host: o.host ?? '127.0.0.1', port: 24480 }),
probe: async () => {
probed += 1;
const g = held('probe');
if (g) await g;
return probes.length ? probes.shift() : false;
},
os,
sleep: o.sleep || ((ms) => {
sleeps.push(ms);
return Promise.resolve();
}),
log: (line) => logs.push(line),
status: (state, message) => {
statuses.push([state, message]);
order.push(`status:${message}`);
},
onUp: () => {
ups += 1;
order.push('onUp');
if (o.upError) throw new Error(o.upError);
},
});
return {
sup, settings, machine, calls, sleeps, statuses, logs, order,
probed: () => probed,
ups: () => ups,
isHeld: (key) => typeof gates[key] === 'function',
release: (key) => gates[key](),
/** The machine actions, without the host check every kick starts with. */
actions: () => calls.filter((c) => !c.startsWith('isLocalHost')),
};
}
/** A sleep that waits until the test lets it go — to look at a run mid-flight. */
function heldSleep() {
const held = [];
const sleep = (ms, signal) => new Promise((resolve) => held.push({ ms, signal, release: resolve }));
return { sleep, held, releaseAll: () => held.splice(0).forEach((h) => h.release()) };
}
const tick = () => new Promise((resolve) => setImmediate(resolve));
/* ── a run, start to finish ─────────────────────────────────────────── */
test('not running: opens ProdCom, and the first probe answers', async () => {
const r = rig({ probes: [true] });
assert.equal(r.sup.kick('ECONNREFUSED'), null);
await r.sup.idle();
assert.deepEqual(r.actions(), ['isRunning', 'launch']);
assert.deepEqual(r.statuses, [START(1), BACK]);
assert.deepEqual(r.sleeps, [5000]);
assert.equal(r.probed(), 1);
assert.ok(r.logs.includes('ProdCom is up after 1 attempt(s)'));
assert.equal(r.ups(), 1, 'the module is told to reconnect now');
assert.equal(r.order[r.order.length - 1], 'onUp', '…after the tile has been told');
// The run ended well: the module's own reconnect does the rest, and fail() may speak for itself again.
assert.equal(r.sup.message(), null);
});
test('not running: three tries that never answer, then it gives up and stays quiet', async () => {
const r = rig({ probes: [false, false, false] });
r.sup.kick('ECONNREFUSED');
await r.sup.idle();
assert.deepEqual(r.actions(), ['isRunning', 'launch', 'isRunning', 'launch', 'isRunning', 'launch']);
assert.deepEqual(r.sleeps, [5000, 5000, 5000]);
assert.equal(r.probed(), 3);
assert.deepEqual(r.statuses, [START(1), START(2), START(3), NO_START]);
assert.ok(r.logs.includes(NO_START[1]));
assert.equal(r.sup.message(), NO_START[1]); // fail() keeps this instead of "unreachable"
assert.equal(r.ups(), 0, 'no reconnect nudge for a run that failed');
const before = { statuses: r.statuses.length, sleeps: r.sleeps.length, probed: r.probed() };
for (let i = 0; i < 5; i += 1) assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
await r.sup.idle();
assert.equal(r.actions().length, 6, 'no machine action once given up');
assert.equal(r.statuses.length, before.statuses);
assert.equal(r.sleeps.length, before.sleeps);
assert.equal(r.probed(), before.probed);
});
test('a reconnect nudge that throws does not spoil the run', async () => {
const r = rig({ probes: [true], upError: 'boom' });
r.sup.kick('ECONNREFUSED');
await r.sup.idle();
assert.deepEqual(r.statuses, [START(1), BACK]);
assert.ok(r.logs.some((l) => l.includes('reconnect nudge failed (boom)')));
assert.equal(r.sup.message(), null);
});
/* ── the API is off ─────────────────────────────────────────────────── */
test('running with the API off: writes the preference, checks it took, restarts ProdCom once, then only looks and probes', async () => {
const r = rig({ machine: { running: true, pref: null }, probes: [false, false, true] });
r.sup.kick('ECONNREFUSED');
await r.sup.idle();
assert.deepEqual(r.actions(), [
'isRunning', 'readApiPref', 'writeApiPref', 'readApiPref', 'quit', 'launch',
'isRunning', 'isRunning', // attempts 2 and 3: still there, so nothing but the probe
]);
assert.deepEqual(r.statuses, [API_OFF_RESTART, BACK]);
assert.deepEqual(r.sleeps, [5000, 5000, 5000]);
assert.equal(r.probed(), 3);
assert.equal(r.calls.filter((c) => c === 'quit').length, 1);
assert.ok(r.logs.includes('ProdCom is up after 3 attempt(s)'));
});
test('the restart waits for the preference to read on: when it does not stick, nothing is quit', async () => {
for (const [pref, what] of [[null, 'unset'], [false, 'off']]) {
for (const apiRestart of [true, false]) {
const r = rig({ machine: { running: true, pref }, writeSticks: false, settings: { apiRestart } });
r.sup.kick('ECONNREFUSED');
await r.sup.idle();
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'writeApiPref', 'readApiPref'], `pref ${pref}, apiRestart ${apiRestart}`);
assert.deepEqual(r.statuses, [NOT_STUCK(what)]);
assert.equal(r.sleeps.length, 0);
assert.equal(r.probed(), 0);
assert.equal(r.sup.message(), NOT_STUCK(what)[1]);
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
assert.equal(r.actions().length, 4);
}
}
});
test('a preference that cannot be written: says so, restarts nothing', async () => {
const r = rig({ machine: { running: true, pref: false }, writeError: "Could not turn on ProdCom's API (exit 1: no)" });
r.sup.kick('ECONNREFUSED');
await r.sup.idle();
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'writeApiPref']);
assert.deepEqual(r.statuses, [['error', "Could not turn on ProdCom's API (exit 1: no)"]]);
});
test('running with the API off and restarting not allowed: writes the preference and says so', async () => {
const r = rig({ machine: { running: true, pref: false }, settings: { apiRestart: false } });
r.sup.kick('ECONNREFUSED');
await r.sup.idle();
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'writeApiPref', 'readApiPref']);
assert.deepEqual(r.statuses, [API_OFF_STAY]);
assert.equal(r.sleeps.length, 0);
assert.equal(r.probed(), 0);
assert.equal(r.sup.message(), API_OFF_STAY[1]);
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
assert.equal(r.actions().length, 4);
});
test('after the one restart, a ProdCom that is not running is opened again — never quit a second time', async () => {
const r = rig({ runningSeq: [true, false, true], machine: { pref: null }, probes: [false, false, true] });
r.sup.kick('ECONNREFUSED');
await r.sup.idle();
assert.deepEqual(r.actions(), [
'isRunning', 'readApiPref', 'writeApiPref', 'readApiPref', 'quit', 'launch',
'isRunning', 'launch', // attempt 2: gone again — a launch
'isRunning', // attempt 3: there; just the probe
]);
assert.deepEqual(r.statuses, [API_OFF_RESTART, START(2), BACK]);
assert.equal(r.calls.filter((c) => c === 'quit').length, 1);
assert.ok(r.logs.includes('ProdCom is up after 3 attempt(s)'));
});
/* ── the API is on, and the port is silent ──────────────────────────── */
test('running with the API on: it is looked at again each attempt, and only the last blames the port', async () => {
const r = rig({ machine: { running: true, pref: true } });
r.sup.kick('ECONNREFUSED');
await r.sup.idle();
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'isRunning', 'readApiPref', 'isRunning', 'readApiPref']);
assert.deepEqual(r.statuses, [API_PORT], 'no verdict before the third look');
assert.deepEqual(r.sleeps, [5000, 5000, 5000]);
assert.equal(r.probed(), 3);
assert.equal(r.sup.message(), API_PORT[1]);
assert.equal(r.calls.includes('writeApiPref'), false);
assert.equal(r.calls.includes('quit'), false);
assert.equal(r.calls.includes('launch'), false);
});
test('running with the API on, and it starts answering on the second look: no blame at all', async () => {
const r = rig({ machine: { running: true, pref: true }, probes: [false, true] });
r.sup.kick('ECONNREFUSED');
await r.sup.idle();
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'isRunning', 'readApiPref']);
assert.deepEqual(r.statuses, [BACK]);
assert.ok(r.logs.includes('ProdCom is up after 2 attempt(s)'));
});
test('running with the API on, then gone on the second look (it was quitting): it is opened', async () => {
const r = rig({ runningSeq: [true, false], machine: { pref: true }, probes: [false, true] });
r.sup.kick('ECONNREFUSED');
await r.sup.idle();
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'isRunning', 'launch']);
assert.deepEqual(r.statuses, [START(2), BACK]);
assert.ok(r.logs.includes('ProdCom is up after 2 attempt(s)'));
});
test('gone only on the last look: opened, and if it still will not answer that is "would not start"', async () => {
const r = rig({ runningSeq: [true, true, false], machine: { pref: true } });
r.sup.kick('ECONNREFUSED');
await r.sup.idle();
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'isRunning', 'readApiPref', 'isRunning', 'launch']);
assert.deepEqual(r.statuses, [START(3), NO_START]);
});
test('a ProdCom this run just opened, still starting up with its API on, is waited for — not blamed on the port', async () => {
// Attempt 1: not running → opened; the 5 s probe is false. Attempt 2: it is running now, its pref is on, the port is
// still silent — that is a slow start, not a wrong port.
const r = rig({ runningSeq: [false, true], machine: { pref: true }, probes: [false, true] });
r.sup.kick('ECONNREFUSED');
await r.sup.idle();
assert.deepEqual(r.actions(), ['isRunning', 'launch', 'isRunning', 'readApiPref']);
assert.deepEqual(r.statuses, [START(1), BACK], 'no error status on the way');
assert.deepEqual(r.sleeps, [5000, 5000]);
assert.equal(r.probed(), 2);
assert.ok(r.logs.includes('ProdCom is up after 2 attempt(s)'));
assert.equal(r.calls.includes('quit'), false);
assert.equal(r.calls.includes('writeApiPref'), false);
});
test('a ProdCom this run just opened that turns out to have its API off still gets the one restart', async () => {
const r = rig({ runningSeq: [false, true], machine: { pref: null }, probes: [false, false, true] });
r.sup.kick('ECONNREFUSED');
await r.sup.idle();
assert.deepEqual(r.actions(), [
'isRunning', 'launch',
'isRunning', 'readApiPref', 'writeApiPref', 'readApiPref', 'quit', 'launch',
'isRunning',
]);
assert.deepEqual(r.statuses, [START(1), API_OFF_RESTART, BACK]);
assert.equal(r.calls.filter((c) => c === 'quit').length, 1);
assert.ok(r.logs.includes('ProdCom is up after 3 attempt(s)'));
});
test('a ProdCom this run opened that never answers, API on: three tries, then "would not start"', async () => {
const r = rig({ runningSeq: [false, true, true], machine: { pref: true }, probes: [false, false, false] });
r.sup.kick('ECONNREFUSED');
await r.sup.idle();
assert.deepEqual(r.actions(), ['isRunning', 'launch', 'isRunning', 'readApiPref', 'isRunning', 'readApiPref']);
assert.deepEqual(r.statuses, [START(1), NO_START]);
});
/* ── when there is nothing to do ────────────────────────────────────── */
test('nothing to do for a ProdCom on another machine', async () => {
const r = rig({ host: '10.1.1.50', machine: { local: false } });
const why = r.sup.kick('ECONNREFUSED');
assert.equal(why, 'ProdCom at 10.1.1.50 is not on this machine');
for (let i = 0; i < 4; i += 1) r.sup.kick('ECONNREFUSED');
await r.sup.idle();
assert.deepEqual(r.actions(), []);
assert.equal(r.statuses.length, 0);
assert.equal(r.logs.filter((l) => l === why).length, 1, 'the reason is logged once, not on every tick');
assert.equal(r.sup.message(), null);
});
test('nothing to do when switched off', async () => {
const r = rig({ settings: { autoStart: false } });
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
r.sup.kick('ECONNREFUSED');
await r.sup.idle();
assert.deepEqual(r.calls, []);
assert.equal(r.statuses.length, 0);
assert.equal(r.logs.length, 1);
});
test('the setting is read live: switching it on later takes effect without a new supervisor', async () => {
const r = rig({ settings: { autoStart: false }, probes: [true] });
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
r.settings.autoStart = true;
assert.equal(r.sup.kick('ECONNREFUSED'), null);
await r.sup.idle();
assert.deepEqual(r.statuses, [START(1), BACK]);
});
test('nothing to do off macOS', async () => {
const r = rig({ platform: 'win32' });
assert.equal(r.sup.kick('ECONNREFUSED'), 'ProdCom auto-start is macOS only');
r.sup.kick('ECONNREFUSED');
await r.sup.idle();
assert.deepEqual(r.actions(), []);
assert.equal(r.statuses.length, 0);
assert.equal(r.logs.filter((l) => l === 'ProdCom auto-start is macOS only').length, 1);
});
test('a kick while a run is in progress does nothing', async () => {
const h = heldSleep();
const r = rig({ sleep: h.sleep, probes: [true] });
assert.equal(r.sup.kick('ECONNREFUSED'), null);
await tick();
assert.equal(h.held.length, 1, 'the run is waiting out its five seconds');
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
assert.equal(typeof r.sup.kick('ETIMEDOUT'), 'string');
assert.equal(r.sup.message(), START(1)[1], 'while it works, its words are the tile\'s');
assert.deepEqual(r.actions(), ['isRunning', 'launch'], 'no second run started');
h.releaseAll();
await r.sup.idle();
assert.deepEqual(r.statuses, [START(1), BACK]);
});
test('kick() never throws into the reconnect loop, whatever the machine layer does', async () => {
const r = rig();
const sup = createSupervisor({
settings: () => r.settings,
target: () => ({ host: '127.0.0.1', port: 24480 }),
os: { platform: 'darwin', isLocalHost: () => { throw new Error('no network interfaces'); } },
probe: async () => true,
sleep: () => Promise.resolve(),
log: (line) => r.logs.push(line),
status: (s, m) => r.statuses.push([s, m]),
});
assert.equal(sup.kick('ECONNREFUSED'), 'ProdCom auto-start could not decide (no network interfaces)');
assert.equal(r.statuses.length, 0);
});
/* ── an outage ends, a new one begins ───────────────────────────────── */
test('after giving up, a successful connection re-arms it for the next outage', async () => {
const r = rig({ machine: { running: true, pref: true } });
r.sup.kick('ECONNREFUSED');
await r.sup.idle();
const first = r.actions().length;
assert.equal(first, 6);
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
assert.equal(r.actions().length, first);
r.sup.noteReachable();
assert.equal(r.sup.message(), null);
assert.equal(r.sup.kick('ECONNREFUSED'), null);
await r.sup.idle();
assert.equal(r.actions().length, first * 2);
assert.deepEqual(r.statuses, [API_PORT, API_PORT]);
});
test('a second outage logs its reasons again', async () => {
const r = rig({ host: '10.1.1.50', machine: { local: false } });
const why = 'ProdCom at 10.1.1.50 is not on this machine';
r.sup.kick('ECONNREFUSED');
r.sup.kick('ECONNREFUSED');
assert.equal(r.logs.filter((l) => l === why).length, 1);
r.sup.noteReachable(); // the first outage is over
r.sup.kick('ECONNREFUSED');
r.sup.kick('ECONNREFUSED');
assert.equal(r.logs.filter((l) => l === why).length, 2);
// And a run that starts clears them too: "has given up" is said once per outage, not once ever.
const g = rig({ machine: { running: true, pref: true } });
const gaveUp = 'ProdCom auto-start has given up until ProdCom answers';
g.sup.kick('ECONNREFUSED');
await g.sup.idle();
g.sup.kick('ECONNREFUSED');
g.sup.kick('ECONNREFUSED');
assert.equal(g.logs.filter((l) => l === gaveUp).length, 1);
g.sup.noteReachable();
g.sup.kick('ECONNREFUSED');
await g.sup.idle();
g.sup.kick('ECONNREFUSED');
assert.equal(g.logs.filter((l) => l === gaveUp).length, 2);
});
test('a run that starts clears the reasons already logged, even with no connection in between', async () => {
const h = heldSleep();
const r = rig({ sleep: h.sleep, probes: [true, true] });
const busy = 'ProdCom auto-start is already working on it';
const said = () => r.logs.filter((l) => l === busy).length;
r.sup.kick('ECONNREFUSED');
await tick();
r.sup.kick('ECONNREFUSED');
r.sup.kick('ECONNREFUSED');
assert.equal(said(), 1);
h.releaseAll();
await r.sup.idle(); // the run ended well; the module has not connected (noteReachable) yet
assert.equal(r.sup.kick('ECONNREFUSED'), null, 'a new run');
await tick();
r.sup.kick('ECONNREFUSED');
assert.equal(said(), 2, 'its reason is logged again');
h.releaseAll();
await r.sup.idle();
});
test('a connection that succeeds mid-run ends the run without another word', async () => {
const h = heldSleep();
const r = rig({ sleep: h.sleep, probes: [true] });
r.sup.kick('ECONNREFUSED');
await tick();
assert.deepEqual(r.statuses, [START(1)]);
r.sup.noteReachable(); // the module got through on its own while we waited
h.releaseAll();
await r.sup.idle();
assert.equal(r.probed(), 0, 'no probe after the outage was over');
assert.deepEqual(r.statuses, [START(1)], 'nothing overwrites the module\'s own "Live"');
assert.equal(r.ups(), 0);
assert.equal(r.sup.message(), null);
assert.equal(r.sup.kick('ECONNREFUSED'), null, 'and the next outage is a fresh run');
await tick();
assert.deepEqual(r.statuses, [START(1), START(1)]);
r.sup.stop();
await r.sup.idle();
});
/* ── stop() and noteReachable() land mid-run ────────────────────────── */
test('stop() during the sleep ends the run: no more launches, no probes', async () => {
const h = heldSleep();
const r = rig({ sleep: h.sleep, probes: [true] });
r.sup.kick('ECONNREFUSED');
await tick();
assert.equal(h.held.length, 1);
assert.equal(h.held[0].signal.aborted, false);
r.sup.stop();
assert.equal(h.held[0].signal.aborted, true, 'the sleep was told to cut itself short');
await r.sup.idle(); // settles even though the fake sleep never does
assert.deepEqual(r.actions(), ['isRunning', 'launch']);
assert.equal(r.probed(), 0);
assert.deepEqual(r.statuses, [START(1)]);
assert.equal(r.sup.message(), null);
assert.equal(r.sup.kick('ECONNREFUSED'), 'stopped');
h.releaseAll();
await tick();
assert.deepEqual(r.actions(), ['isRunning', 'launch']);
assert.equal(r.probed(), 0);
assert.equal(r.ups(), 0);
});
// Each machine call a run makes, held in flight; the run is ended (by the module
// connecting, or by stop()) while the call is out, and nothing may follow it:
// no status, no log, no further call, no reconnect nudge.
const MID_CALL = [
['isRunning', 'isRunning#1', { machine: { running: false } }],
['launch', 'launch#1', { machine: { running: false } }],
['readApiPref', 'readApiPref#1', { machine: { running: true, pref: null } }],
['writeApiPref', 'writeApiPref#1', { machine: { running: true, pref: null } }],
['readApiPref (the check that it took)', 'readApiPref#2', { machine: { running: true, pref: null } }],
['quit', 'quit#1', { machine: { running: true, pref: null } }],
['launch (after the quit)', 'launch#1', { machine: { running: true, pref: null } }],
['probe', 'probe#1', { machine: { running: false }, probes: [true] }],
];
for (const [label, key, setup] of MID_CALL) {
for (const how of ['noteReachable', 'stop']) {
test(`${how}() while ${label} is in flight: nothing follows`, async () => {
const r = rig({ ...setup, hold: { [key]: true } });
r.sup.kick('ECONNREFUSED');
await tick();
assert.ok(r.isHeld(key), `${key} is in flight`);
r.sup[how]();
const snapshot = { calls: [...r.calls], statuses: [...r.statuses], logs: [...r.logs], order: [...r.order], sleeps: [...r.sleeps] };
r.release(key);
await r.sup.idle();
await tick();
assert.deepEqual(r.calls, snapshot.calls, 'no further machine call');
assert.deepEqual(r.statuses, snapshot.statuses, 'no status');
assert.deepEqual(r.logs, snapshot.logs, 'no log line');
assert.deepEqual(r.order, snapshot.order, 'no nudge');
assert.deepEqual(r.sleeps, snapshot.sleeps, 'no further wait');
assert.equal(r.probed(), key === 'probe#1' ? 1 : 0, 'no further probe');
assert.equal(r.ups(), 0);
});
}
}
// …and the same when the call that was in flight ends in an error: the catch
// must not report a failure of a run that is already over.
const MID_CALL_ERROR = [
['isRunning', 'isRunning#1', { machine: { running: false }, runningError: 'pgrep failed' }],
['launch', 'launch#1', { machine: { running: false }, launchError: 'ProdCom is not installed on this machine' }],
['readApiPref', 'readApiPref#1', { machine: { running: true, pref: null }, readError: 'defaults failed' }],
['writeApiPref', 'writeApiPref#1', { machine: { running: true, pref: null }, writeError: 'write failed' }],
['quit', 'quit#1', { machine: { running: true, pref: null }, quitError: 'ProdCom would not quit' }],
['launch (after the quit)', 'launch#1', { machine: { running: true, pref: null }, launchError: 'launch failed' }],
];
for (const [label, key, setup] of MID_CALL_ERROR) {
for (const how of ['noteReachable', 'stop']) {
test(`${how}() while ${label} is in flight and then fails: no error is reported`, async () => {
const r = rig({ ...setup, hold: { [key]: true } });
r.sup.kick('ECONNREFUSED');
await tick();
assert.ok(r.isHeld(key), `${key} is in flight`);
r.sup[how]();
const snapshot = { calls: [...r.calls], statuses: [...r.statuses], logs: [...r.logs] };
r.release(key);
await r.sup.idle();
await tick();
assert.deepEqual(r.calls, snapshot.calls);
assert.deepEqual(r.statuses, snapshot.statuses, 'no error status');
assert.deepEqual(r.logs, snapshot.logs, 'no error log');
assert.equal(r.sup.message(), null, 'and nothing latched as given up');
if (how === 'noteReachable') assert.equal(r.sup.kick('ECONNREFUSED'), null, 'the next outage still gets a run');
r.sup.stop();
await r.sup.idle();
});
}
}
test('ProdCom is not installed: says so and gives up', async () => {
const r = rig({ launchError: 'ProdCom is not installed on this machine' });
r.sup.kick('ECONNREFUSED');
await r.sup.idle();
assert.deepEqual(r.actions(), ['isRunning', 'launch']);
assert.deepEqual(r.statuses, [['error', 'ProdCom is not installed on this machine']]);
assert.equal(r.sleeps.length, 0);
assert.equal(r.probed(), 0);
assert.equal(r.sup.message(), 'ProdCom is not installed on this machine');
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
assert.equal(r.actions().length, 2);
});
test('ProdCom will not quit: says so, does not open it again, gives up', async () => {
const r = rig({ machine: { running: true, pref: null }, quitError: 'ProdCom would not quit' });
r.sup.kick('ECONNREFUSED');
await r.sup.idle();
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'writeApiPref', 'readApiPref', 'quit']);
assert.deepEqual(r.statuses, [API_OFF_RESTART, ['error', 'ProdCom would not quit']]);
assert.equal(r.sleeps.length, 0);
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
});
test('a machine call that fails for another reason gives up with its message', async () => {
const r = rig({ machine: { running: true }, readError: "Could not read ProdCom's settings (timed out)" });
r.sup.kick('ETIMEDOUT');
await r.sup.idle();
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref'], 'not knowing the setting is not a reason to restart ProdCom');
assert.deepEqual(r.statuses, [['error', "Could not read ProdCom's settings (timed out)"]]);
});
/* ── the real machine layer, on a fake execFile ─────────────────────── */
const ok = (stdout = '') => ({ stdout });
const exited = (code, stderr = '') => ({ error: Object.assign(new Error('Command failed'), { code }), stderr });
const timedOut = () => ({ error: Object.assign(new Error('timed out'), { killed: true, signal: 'SIGTERM', code: null }) });
const BUNDLE = 'com.prodcom.prodcom';
/** A fake child_process.execFile answering from `script(file, args, n)` (a result, or a promise of one). */
function fakeExecFile(script) {
const calls = [];
const execFile = (file, args, opts, cb) => {
calls.push({ file, args, opts, line: [file, ...args].join(' ') });
Promise.resolve(script(file, args, calls.length)).then((res) => {
const r = res || {};
cb(r.error || null, r.stdout || '', r.stderr || '');
});
};
return { execFile, calls, lines: () => calls.map((c) => c.line) };
}
/** A fake clock: sleeping advances it, so a wait that would last ten seconds takes no time. */
function fakeClock() {
let t = 0;
const slept = [];
return {
now: () => t,
sleep: (ms) => {
slept.push(ms);
t += ms;
return Promise.resolve(true);
},
slept,
};
}
test('default os: reading the preference — 1, 0, a missing key, a timeout', async () => {
const answers = [ok('1\n'), ok('0\n'), exited(1, 'The domain/default pair of (com.prodcom.prodcom, applicationAPIEnabled) does not exist'), timedOut()];
const x = fakeExecFile(() => answers.shift());
const os = defaultOs({ execFile: x.execFile });
assert.equal(await os.readApiPref(), true);
assert.equal(await os.readApiPref(), false);
assert.equal(await os.readApiPref(), null, 'a missing key is "not set", not an error');
await assert.rejects(os.readApiPref(), /^Error: Could not read ProdCom's settings \(timed out\)$/);
assert.equal(x.lines()[0], `/usr/bin/defaults read ${BUNDLE} applicationAPIEnabled`);
assert.equal(x.calls[0].opts.timeout, 5000);
});
test('default os: writing the preference, and what a failure says', async () => {
const x = fakeExecFile((file, args, n) => (n === 1 ? ok() : exited(1, '\n Could not write domain com.prodcom.prodcom; exiting\nmore')));
const os = defaultOs({ execFile: x.execFile });
await os.writeApiPref();
assert.equal(x.lines()[0], `/usr/bin/defaults write ${BUNDLE} applicationAPIEnabled -bool true`);
await assert.rejects(os.writeApiPref(), /^Error: Could not turn on ProdCom's API \(exit 1: Could not write domain com\.prodcom\.prodcom; exiting\)$/);
});
test('default os: opening ProdCom is done in the background (-g), and a failed open reads as not installed', async () => {
const answers = [ok(), exited(1, 'Unable to find application'), timedOut()];
const x = fakeExecFile(() => answers.shift());
const os = defaultOs({ execFile: x.execFile });
await os.launch();
assert.equal(x.lines()[0], `/usr/bin/open -g -b ${BUNDLE}`);
assert.ok(x.calls[0].args.includes('-g'));
await assert.rejects(os.launch(), /^Error: ProdCom is not installed on this machine$/);
await assert.rejects(os.launch(), /^Error: Could not open ProdCom \(timed out\)$/);
});
test('default os: is ProdCom running — pgrep 0 / 1, anything else is an error', async () => {
const answers = [ok('123\n'), exited(1), exited(2, 'pgrep: bad'), timedOut()];
const x = fakeExecFile(() => answers.shift());
const os = defaultOs({ execFile: x.execFile });
assert.equal(await os.isRunning(), true);
assert.equal(await os.isRunning(), false);
await assert.rejects(os.isRunning(), /Could not check whether ProdCom is running \(exit 2: pgrep: bad\)/);
await assert.rejects(os.isRunning(), /Could not check whether ProdCom is running \(timed out\)/);
assert.equal(x.lines()[0], '/usr/bin/pgrep -x ProdCom');
});
test('default os: quit, the polite way — asked by Apple Event, then waited for', async () => {
let pgreps = 0;
const x = fakeExecFile((file) => {
if (file === '/usr/bin/osascript') return ok();
pgreps += 1;
return pgreps < 3 ? ok('123\n') : exited(1); // still there twice, then gone
});
const clock = fakeClock();
const os = defaultOs({ execFile: x.execFile, sleep: clock.sleep, now: clock.now });
await os.quit(new AbortController().signal);
assert.deepEqual(x.lines(), [
`/usr/bin/osascript -e tell application id "${BUNDLE}" to quit`,
'/usr/bin/pgrep -x ProdCom', '/usr/bin/pgrep -x ProdCom', '/usr/bin/pgrep -x ProdCom',
]);
assert.equal(x.calls[0].opts.timeout, 5000, 'the Apple Event gets five seconds');
assert.deepEqual(clock.slept, [500, 500]);
});
test('default os: quit, when it will not be asked — SIGTERM, then waited for', async () => {
let pgreps = 0;
const x = fakeExecFile((file) => {
if (file === '/usr/bin/osascript') return exited(1, 'execution error: Not authorized to send Apple events (-1743)');
if (file === '/usr/bin/pkill') return ok();
pgreps += 1;
return pgreps < 2 ? ok('123\n') : exited(1);
});
const clock = fakeClock();
const os = defaultOs({ execFile: x.execFile, sleep: clock.sleep, now: clock.now });
await os.quit();
assert.deepEqual(x.lines().map((l) => l.split(' ').slice(0, 3).join(' ')), [
'/usr/bin/osascript -e tell',
'/usr/bin/pkill -x -TERM',
'/usr/bin/pgrep -x ProdCom',
'/usr/bin/pgrep -x ProdCom',
]);
assert.deepEqual(x.calls[1].args, ['-x', '-TERM', 'ProdCom']);
});
test('default os: quit, and it will not go — "would not quit" after ten seconds', async () => {
const x = fakeExecFile((file) => (file === '/usr/bin/pgrep' ? ok('123\n') : ok()));
const clock = fakeClock();
const os = defaultOs({ execFile: x.execFile, sleep: clock.sleep, now: clock.now });
await assert.rejects(os.quit(), /^Error: ProdCom would not quit$/);
assert.equal(clock.now(), 10000);
assert.equal(x.calls.filter((c) => c.file === '/usr/bin/pkill').length, 0, 'the Apple Event worked, so no SIGTERM');
});
test('default os: quit, ended while the Apple Event is out: no SIGTERM, no look at the process list', async () => {
const ctl = new AbortController();
let finish;
const x = fakeExecFile((file) => {
if (file === '/usr/bin/osascript') return new Promise((resolve) => { finish = () => resolve(exited(1, 'denied')); });
return ok('123\n');
});
const os = defaultOs({ execFile: x.execFile, sleep: fakeClock().sleep });
const quitting = os.quit(ctl.signal);
await tick();
ctl.abort();
finish();
await assert.rejects(quitting, /^Error: cancelled$/);
assert.equal(x.calls.length, 1, 'only the osascript was ever run');
});
test('default os: quit, ended while SIGTERM is out: no look at the process list', async () => {
const ctl = new AbortController();
let finish;
const x = fakeExecFile((file) => {
if (file === '/usr/bin/osascript') return exited(1, 'denied');
if (file === '/usr/bin/pkill') return new Promise((resolve) => { finish = () => resolve(ok()); });
return ok('123\n');
});
const os = defaultOs({ execFile: x.execFile, sleep: fakeClock().sleep });
const quitting = os.quit(ctl.signal);
await tick();
ctl.abort();
finish();
await assert.rejects(quitting, /^Error: cancelled$/);
assert.deepEqual(x.calls.map((c) => c.file), ['/usr/bin/osascript', '/usr/bin/pkill']);
});
test('default os: quit, ended while it waits for the process to go: no further look', async () => {
const ctl = new AbortController();
const x = fakeExecFile((file) => (file === '/usr/bin/pgrep' ? ok('123\n') : ok()));
const os = defaultOs({
execFile: x.execFile,
sleep: () => { ctl.abort(); return Promise.resolve(false); }, // the run ends during the first wait
});
await assert.rejects(os.quit(ctl.signal), /^Error: cancelled$/);
assert.equal(x.calls.filter((c) => c.file === '/usr/bin/pgrep').length, 1);
});
test('default os: quit, already ended before it starts: it does nothing at all', async () => {
const ctl = new AbortController();
ctl.abort();
const x = fakeExecFile(() => ok());
const os = defaultOs({ execFile: x.execFile });
await assert.rejects(os.quit(ctl.signal), /^Error: cancelled$/);
assert.equal(x.calls.length, 0);
});
/* ── the pieces that need no machine ────────────────────────────────── */
test('isLocalHostName: this machine by every name it has, and no other', () => {
const addrs = ['127.0.0.1', '::1', 'fe80::1', '10.3.11.152', 'fe80::aede:48ff:fe00:1122'];
const here = 'AB-ProPresenter.local';
for (const host of ['', 'localhost', 'LocalHost', '127.0.0.1', '::1', '[::1]', '0.0.0.0',
'10.3.11.152', 'FE80::1', 'fe80:0:0:0:0:0:0:1', 'fe80::1%lo0',
'ab-propresenter.local', 'AB-PROPRESENTER', 'ab-propresenter', 'ab-propresenter.local.']) {
assert.equal(isLocalHostName(host, here, addrs), true, `"${host}" is this machine`);
}
for (const host of ['10.1.1.50', 'prodcom.local', 'na-ingest', 'example.com', '10.3.11.153']) {
assert.equal(isLocalHostName(host, here, addrs), false, `"${host}" is not`);
}
// A hostname without .local matches the same machine reached as .local.
assert.equal(isLocalHostName('studio.local', 'Studio', []), true);
});
test('parseApiPref: defaults prints a bool as 1 or 0', () => {
assert.equal(parseApiPref('1\n'), true);
assert.equal(parseApiPref('0\n'), false);
assert.equal(parseApiPref(' true '), true);
assert.equal(parseApiPref(''), null);
assert.equal(parseApiPref('banana'), null);
});
test('the default sleep is cut short by its signal and leaves no timer behind', async () => {
const ctl = new AbortController();
const slept = abortableSleep(60 * 60 * 1000, ctl.signal); // an hour — would hold the test open if it were left running
ctl.abort();
assert.equal(await slept, false);
assert.equal(await abortableSleep(60 * 60 * 1000, ctl.signal), false, 'already aborted: returns at once');
});
+55
View File
@@ -0,0 +1,55 @@
# ProPresenter Look — a ProdDash module
Shows the audience **look** live in ProPresenter right now, as big as the
tile allows, so anyone away from the ProPresenter machine can tell at a glance
which look the outputs are on.
- The look's name fills the tile and **flashes** when it changes.
- **Modified** appears when someone changed the look by hand and it no longer
matches any saved look.
- **since 10:42 AM** says when the look last changed.
- If ProPresenter stops answering, the status dot goes red and the tile keeps
the **last known** look on screen, dimmed, until it comes back by itself.
## Install
This folder is a standard ProdDash module (id `propresenter-looks`). Per the
ProdDash module guide, it installs one of two ways:
1. **From the admin page's catalog**, once the folder is in the ProdDash repo
at `modules/propresenter-looks/`: `/admin` → Available modules →
*ProPresenter Look* → Install.
2. **Drop it in**: copy this folder to the ProdDash data directory's
`modules/propresenter-looks/` and restart ProdDash.
- macOS: `~/Library/Application Support/ProdDash/modules/`
- Windows: `%APPDATA%\ProdDash\modules\`
Then add a tile from **+ Add tile → ProPresenter Look**.
## Configure (in `/admin`)
| Setting | What to enter |
| --- | --- |
| ProPresenter | Host / IP and port from ProPresenter's Settings › Network (Network enabled). |
| Password | Only if ProPresenter's network API asks for one. |
Each tile's gear has three switches: **Heading**, **Live status**, **Since**.
## How it works
The server entry polls ProPresenter's network API once a second
(`GET /v1/look/current`, plus `GET /v1/looks` every ten seconds to name the
saved look it matches) and streams the result to tiles over SSE. Browsers
never talk to ProPresenter. No dependencies. Needs ProdDash 1.5.0 or later and
ProPresenter 7.9+ (or 20+).
## Testing without ProPresenter
```bash
node tools/pp-looks-mock.js 1601
```
Point the module at host `127.0.0.1`, port `1601`. The live look changes every
8 seconds (`--every 3` for faster) and every fourth change is a hand-modified
look. `--password <pw>` demands a password; `--no-list` acts like a
ProPresenter without `GET /v1/looks`.
+192
View File
@@ -0,0 +1,192 @@
/* ProPresenter Look — client part.
The server part polls ProPresenter and streams the live audience look
here over SSE; the tile only renders. The look's name fills the tile so
it reads from across the booth, flashes once when it changes, and stays
on screen dimmed — marked "last known" — while ProPresenter is
unreachable. */
export default function create({ root, moduleApi }) {
let view = null;
let feedOffline = false;
let stream = null;
let resizeObserver = null;
let flashTimer = null;
let lastName = null;
root.innerHTML = `
<div class="pl-screen">
<header class="pl-top">
<div class="pl-heading">Look</div>
<div class="pl-live"><span class="pl-live-dot"></span><span class="pl-live-text">Connecting…</span></div>
</header>
<div class="pl-name-box"><div class="pl-name is-waiting">Connecting…</div></div>
<footer class="pl-meta">
<span class="pl-tag" hidden>Modified</span>
<span class="pl-since" hidden></span>
</footer>
</div>`;
const screenEl = root.querySelector('.pl-screen');
const topEl = root.querySelector('.pl-top');
const headingEl = root.querySelector('.pl-heading');
const liveEl = root.querySelector('.pl-live');
const liveTextEl = root.querySelector('.pl-live-text');
const nameBox = root.querySelector('.pl-name-box');
const nameEl = root.querySelector('.pl-name');
const metaEl = root.querySelector('.pl-meta');
const tagEl = root.querySelector('.pl-tag');
const sinceEl = root.querySelector('.pl-since');
const on = (key) => moduleApi.instanceSettings[key] !== false;
function timeText(ms) {
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
/** Biggest font size at which the name fits its box without breaking a word. */
function fitName() {
const MIN = 11;
const boxHeight = nameBox.clientHeight;
const boxWidth = nameBox.clientWidth;
nameEl.style.overflowWrap = 'normal';
if (boxHeight < MIN || !boxWidth) {
nameEl.style.fontSize = `${MIN}px`;
return;
}
const fits = (size) => {
nameEl.style.fontSize = `${size}px`;
return nameEl.scrollWidth <= nameBox.clientWidth + 1 && nameEl.scrollHeight <= nameBox.clientHeight + 1;
};
let lo = MIN;
let hi = Math.max(MIN, Math.floor(boxHeight));
let best = MIN;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (fits(mid)) { best = mid; lo = mid + 1; } else { hi = mid - 1; }
}
nameEl.style.fontSize = `${best}px`;
// One word too long even at MIN: let it break rather than overflow.
if (best === MIN && nameEl.scrollWidth > nameBox.clientWidth + 1) nameEl.style.overflowWrap = 'anywhere';
}
function flash() {
screenEl.classList.remove('is-changed');
void screenEl.offsetWidth; // restart the animation
screenEl.classList.add('is-changed');
clearTimeout(flashTimer);
flashTimer = setTimeout(() => screenEl.classList.remove('is-changed'), 2600);
}
function setStatus() {
if (feedOffline) {
moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
} else if (!view) {
moduleApi.setStatus('connecting', 'Connecting…');
} else if (!view.configured) {
moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
} else if (view.reachable) {
moduleApi.setStatus('ok', `Live look: ${view.look ? view.look.name : '—'}`);
} else if (!view.updatedAt && !view.lastError) {
moduleApi.setStatus('connecting', 'Reaching ProPresenter…');
} else {
moduleApi.setStatus('error', `ProPresenter unreachable${view.lastError ? ` (${view.lastError})` : ''} — reconnecting…`);
}
}
function render() {
setStatus();
const offline = feedOffline || Boolean(view && view.configured && !view.reachable && (view.updatedAt || view.lastError));
const look = view && view.look;
screenEl.classList.toggle('is-offline', offline);
headingEl.hidden = !on('showHeading');
liveEl.hidden = !on('showLive') && !offline; // losing the feed always shows
topEl.hidden = headingEl.hidden && liveEl.hidden;
liveEl.classList.toggle('is-live', !offline && Boolean(view && view.reachable));
liveEl.classList.toggle('is-down', offline);
if (feedOffline) liveTextEl.textContent = 'ProdDash offline';
else if (offline) liveTextEl.textContent = look ? 'Unreachable · last known' : 'Unreachable';
else if (view && view.reachable) liveTextEl.textContent = 'Live';
else liveTextEl.textContent = 'Connecting…';
let name;
let waiting = false;
if (look) {
name = look.name;
} else if (view && !view.configured) {
name = 'Set up in Admin';
waiting = true;
} else if (offline) {
name = 'ProPresenter unreachable';
waiting = true;
} else {
name = 'Connecting…';
waiting = true;
}
nameEl.textContent = name;
nameEl.classList.toggle('is-waiting', waiting);
if (look && lastName !== null && lastName !== `${look.name}|${look.modified}`) flash();
if (look) lastName = `${look.name}|${look.modified}`;
tagEl.hidden = !(look && look.modified);
const since = look && view.changedAt && on('showSince');
sinceEl.hidden = !since;
if (since) sinceEl.textContent = `since ${timeText(view.changedAt)}`;
metaEl.hidden = tagEl.hidden && sinceEl.hidden;
fitName();
}
function connect() {
stream = moduleApi.sse('/stream', {
open() {
feedOffline = false;
render();
},
error() {
feedOffline = true;
render();
},
events: {
state(e) {
try {
view = JSON.parse(e.data);
} catch {
return;
}
feedOffline = false;
render();
},
},
});
}
return {
start() {
render();
moduleApi.fetch('/state')
.then((r) => (r.ok ? r.json() : null))
.then((body) => {
if (body && body.state && !view) {
view = body.state;
render();
}
})
.catch(() => {});
connect();
resizeObserver = new ResizeObserver(() => fitName());
resizeObserver.observe(nameBox);
},
stop() {
if (stream) stream.close();
stream = null;
if (resizeObserver) resizeObserver.disconnect();
resizeObserver = null;
clearTimeout(flashTimer);
root.innerHTML = '';
},
};
}
+58
View File
@@ -0,0 +1,58 @@
{
"id": "propresenter-looks",
"name": "ProPresenter Look",
"version": "1.0.0",
"proddash": ">=1.5.0",
"description": "The audience look live in ProPresenter right now",
"permissions": [
{
"kind": "local-network",
"reason": "Reaching ProPresenter on this network"
}
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": {
"w": 2,
"h": 1
},
"defaultSize": {
"w": 4,
"h": 2
},
"configSchema": {
"propresenter": {
"type": "endpoint",
"label": "ProPresenter",
"default": {
"host": "",
"port": 1025
},
"help": "The host and port shown in ProPresenter's Settings › Network, with Network enabled."
},
"password": {
"type": "password",
"label": "Password",
"default": "",
"help": "Only if ProPresenter's network API asks for one. Leave empty otherwise."
}
},
"instanceSchema": {
"showHeading": {
"type": "switch",
"label": "Heading",
"default": true
},
"showLive": {
"type": "switch",
"label": "Live status",
"default": true
},
"showSince": {
"type": "switch",
"label": "Since",
"default": true
}
}
}
+242
View File
@@ -0,0 +1,242 @@
'use strict';
/**
* ProPresenter Look — server part.
*
* Polls ProPresenter's network API for the live audience look and fans it
* out to every tile over SSE. Browsers never talk to ProPresenter directly.
*
* GET /state → { state: <view> } (instant paint for new tiles)
* GET /stream → SSE, `state` events (change-detected, heartbeated)
*
* ProPresenter's routes (ProPresenter 7.9+ / 20+):
* GET /v1/look/current → { id: { uuid, name, index }, screens: [...] }
* GET /v1/looks → [ { id: { uuid, name, index }, screens: [...] }, … ]
* The current look carries a UUID of its own, never one from /v1/looks. Its
* `index` points at the saved look it matches, or equals the number of saved
* looks when someone has changed it by hand and it matches none.
*/
const POLL_MS = 1000; // the look matters the moment it changes
const RETRY_MS = 3000; // while ProPresenter is unreachable
const LOOKS_EVERY = 10; // re-read the saved looks every N polls
const REQUEST_TIMEOUT_MS = 2500;
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
const HEARTBEAT_MS = 15000;
/** Set by init(), read by the routes — both are rebuilt together on remount. */
let current = null;
function emptyView(configured = false) {
return {
configured,
reachable: false,
lastError: '',
look: null, // { name, modified, screens }
changedAt: 0, // ms epoch the look last changed while we watched; 0 = unknown
updatedAt: 0, // ms epoch of the last good answer
};
}
function errorText(err) {
return String(err?.cause?.code || err?.code || err?.message || err || 'failed');
}
/** One look's name, trimmed — ProPresenter leaves the current look's name blank on some versions. */
function lookName(entry) {
return String(entry?.id?.name ?? entry?.name ?? '').trim();
}
module.exports = {
init({ config, log }) {
const streams = new Set();
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
const host = String(ep.host || '').trim();
const port = Number(ep.port) || 1025;
const password = String(config.password || '');
const configured = Boolean(host && port);
let view = emptyView(configured);
let lastFrame = '';
let saved = []; // names of the saved looks, in ProPresenter's order
let savedOk = false;
let tick = 0;
let stopped = false;
let pollTimer = null;
function url(target) {
const built = new URL(`http://${host}:${port}${target}`);
if (password) built.searchParams.set('password', password);
return built;
}
async function requestJson(target) {
const response = await fetch(url(target), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
const err = new Error(`ProPresenter ${response.status} ${response.statusText}`);
err.status = response.status;
throw err;
}
const text = await response.text();
return text ? JSON.parse(text) : null;
}
function broadcast() {
let frame;
try {
frame = JSON.stringify(view);
} catch {
return;
}
// Polls land every second whether anything changed or not; only real
// changes go out, so idle tiles stay idle.
if (frame === lastFrame) return;
lastFrame = frame;
const payload = `event: state\ndata: ${frame}\n\n`;
for (const res of streams) {
try {
res.write(payload);
} catch {
streams.delete(res);
}
}
}
async function refreshSaved() {
try {
const list = await requestJson('/v1/looks');
saved = Array.isArray(list) ? list.map(lookName) : [];
savedOk = true;
} catch (err) {
// An older ProPresenter without the list still answers look/current;
// the live look's own name is shown then.
if (err.status !== 404) throw err;
saved = [];
savedOk = false;
}
}
function resolveLook(live) {
const index = Number(live?.id?.index);
const own = lookName(live);
const screens = Array.isArray(live?.screens) ? live.screens.length : 0;
if (savedOk && Number.isInteger(index) && index >= 0 && index < saved.length) {
return { name: saved[index] || own || `Look ${index + 1}`, modified: false, screens };
}
// Changed by hand, matching no saved look.
const modified = savedOk && Number.isInteger(index) && index >= saved.length;
return { name: own || (modified ? 'Custom look' : 'Unnamed look'), modified, screens };
}
async function pollTick() {
if (stopped) return;
const next = { ...view };
try {
tick += 1;
if (tick % LOOKS_EVERY === 1 || !savedOk) await refreshSaved();
const live = await requestJson('/v1/look/current');
let look = resolveLook(live);
// A look saved since our last read: refresh the list now rather than
// calling it "Custom" for up to ten seconds.
if (look.modified && !view.look?.modified && tick % LOOKS_EVERY !== 1) {
await refreshSaved();
look = resolveLook(live);
}
const before = view.look;
if (before && (before.name !== look.name || before.modified !== look.modified)) {
next.changedAt = Date.now();
log(`look → ${look.name}${look.modified ? ' (modified)' : ''}`);
} else if (!before) {
log(`look is ${look.name}${look.modified ? ' (modified)' : ''}`);
}
next.look = look;
next.reachable = true;
next.lastError = '';
next.updatedAt = Date.now();
} catch (err) {
if (view.reachable) log(`ProPresenter unreachable: ${errorText(err)}`);
next.reachable = false;
next.lastError = errorText(err); // "ECONNREFUSED" beats fetch's "fetch failed"
tick = 0; // re-read the saved looks first when it comes back
}
if (stopped) return;
view = next;
broadcast();
pollTimer = setTimeout(pollTick, view.reachable ? POLL_MS : RETRY_MS);
}
if (configured) {
log(`following the live look on ${host}:${port}`);
pollTick();
} else {
log('no ProPresenter host configured — set one in /admin');
}
const heartbeat = setInterval(() => {
for (const res of streams) {
try {
res.write(': ping\n\n');
} catch {
streams.delete(res);
}
}
}, HEARTBEAT_MS);
heartbeat.unref?.();
current = { streams, getView: () => view };
return {
stop() {
stopped = true;
clearTimeout(pollTimer);
clearInterval(heartbeat);
for (const res of streams) {
try { res.end(); } catch { /* already gone */ }
}
streams.clear();
if (current && current.streams === streams) current = null;
},
health() {
if (!configured) return { status: 'error', message: 'No ProPresenter host configured' };
if (view.reachable) {
return { status: 'ok', message: `Live look on ${host}:${port}: ${view.look ? view.look.name : '—'}` };
}
return {
status: view.updatedAt || view.lastError ? 'error' : 'connecting',
message: view.lastError ? `ProPresenter unreachable: ${view.lastError}` : `Reaching ${host}:${port}…`,
};
},
};
},
routes() {
return {
'GET /state': (req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ state: current ? current.getView() : emptyView() }));
},
'GET /stream': (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write('retry: 3000\n\n');
res.write(`event: state\ndata: ${JSON.stringify(current ? current.getView() : emptyView())}\n\n`);
if (!current) return void res.end();
const { streams } = current;
// A viewer that stops reading (locked tablet, sleeping laptop) must
// not queue frames forever; keepalive probes make the OS notice.
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
streams.add(res);
req.on('close', () => streams.delete(res));
},
};
},
};
+141
View File
@@ -0,0 +1,141 @@
/* ProPresenter Look — the live look's name, as big as the tile allows.
Colors come from the shell's variables only. */
[data-module="propresenter-looks"] .pl-screen {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
user-select: none;
-webkit-user-select: none;
background: linear-gradient(
160deg,
color-mix(in srgb, var(--accent) 12%, transparent),
color-mix(in srgb, var(--bg) 60%, transparent)
);
}
/* ── top row: heading and live status ── */
[data-module="propresenter-looks"] .pl-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-shrink: 0;
min-height: 0;
}
[data-module="propresenter-looks"] .pl-top[hidden],
[data-module="propresenter-looks"] .pl-heading[hidden],
[data-module="propresenter-looks"] .pl-live[hidden],
[data-module="propresenter-looks"] .pl-meta[hidden],
[data-module="propresenter-looks"] .pl-tag[hidden],
[data-module="propresenter-looks"] .pl-since[hidden] {
display: none;
}
[data-module="propresenter-looks"] .pl-heading {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
[data-module="propresenter-looks"] .pl-live {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
}
[data-module="propresenter-looks"] .pl-live-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--border);
transition: background 0.3s ease;
}
[data-module="propresenter-looks"] .pl-live.is-live .pl-live-dot {
background: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent);
}
[data-module="propresenter-looks"] .pl-live.is-down .pl-live-dot {
background: var(--danger);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 16%, transparent);
}
[data-module="propresenter-looks"] .pl-live.is-down .pl-live-text {
color: var(--danger);
}
/* ── the look's name ── */
[data-module="propresenter-looks"] .pl-name-box {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
[data-module="propresenter-looks"] .pl-name {
max-width: 100%;
font-size: 11px;
font-weight: 800;
line-height: 1.05;
text-align: center;
white-space: normal;
word-break: normal;
hyphens: none;
color: var(--text);
}
[data-module="propresenter-looks"] .pl-name.is-waiting {
font-weight: 600;
color: var(--muted);
}
/* Unreachable: the last known look stays, visibly stale. */
[data-module="propresenter-looks"] .pl-screen.is-offline {
background: color-mix(in srgb, var(--danger) 8%, transparent);
}
[data-module="propresenter-looks"] .pl-screen.is-offline .pl-name:not(.is-waiting) {
opacity: 0.45;
}
/* ── footer: modified tag and when it changed ── */
[data-module="propresenter-looks"] .pl-meta {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-shrink: 0;
font-size: 11px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
[data-module="propresenter-looks"] .pl-tag {
padding: 1px 7px;
border-radius: 999px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
font-size: 10px;
color: var(--warn);
background: color-mix(in srgb, var(--warn) 16%, transparent);
}
/* A new look: one bright pulse so a glance catches the change. */
[data-module="propresenter-looks"] .pl-screen.is-changed {
animation: pl-changed 2.6s ease-out;
}
@keyframes pl-changed {
0%, 20% { box-shadow: inset 0 0 0 3px var(--accent); background-color: color-mix(in srgb, var(--accent) 22%, transparent); }
100% { box-shadow: inset 0 0 0 3px transparent; background-color: transparent; }
}
@@ -0,0 +1,69 @@
#!/usr/bin/env node
'use strict';
/**
* Mock ProPresenter looks API, for developing the ProPresenter Look module
* without the real thing.
*
* node tools/pp-looks-mock.js [port] (default 1601)
* --every <seconds> how often the live look changes (default 8)
* --password <pw> demand ?password=<pw> like a locked ProPresenter
* --no-list answer 404 for /v1/looks (an older ProPresenter)
*
* Point the module at host 127.0.0.1 and this port. Every few changes the
* live look is "modified by hand", so it matches no saved look.
*/
const http = require('http');
const crypto = require('crypto');
const args = process.argv.slice(2);
const flag = (name) => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const port = Number(args.find((a) => /^\d+$/.test(a))) || 1601;
const every = Number(flag('--every')) || 8;
const password = flag('--password') || '';
const noList = args.includes('--no-list');
const screen = (on) => ({ video_input: false, media: on, slide: on, announcements: on, props: on, messages: on, presentation: null, mask: null });
const saved = ['Normal', 'Lyrics Only', 'Sermon', 'Countdown', 'Video Only'].map((name, index) => ({
id: { uuid: crypto.randomUUID(), name, index },
screens: [screen(true), screen(index !== 4)],
}));
let live = 0;
let modified = false;
let changes = 0;
const liveUuid = crypto.randomUUID();
setInterval(() => {
changes += 1;
modified = changes % 4 === 0;
if (!modified) live = (live + 1) % saved.length;
console.log(`live look → ${saved[live].id.name}${modified ? ' (modified)' : ''}`);
}, every * 1000);
function currentLook() {
return {
id: { uuid: liveUuid, name: saved[live].id.name, index: modified ? saved.length : live },
screens: saved[live].screens,
};
}
http.createServer((req, res) => {
const url = new URL(req.url, 'http://x');
const send = (code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(body === undefined ? '' : JSON.stringify(body));
};
if (password && url.searchParams.get('password') !== password) return send(401, { error: 'password' });
if (url.pathname === '/version') return send(200, { name: 'Mock', platform: 'mac', os_version: '15', host_description: 'ProPresenter 21.0 (mock)' });
if (url.pathname === '/v1/looks') return noList ? send(404) : send(200, saved);
if (url.pathname === '/v1/look/current') return send(200, currentLook());
send(404);
}).listen(port, () => {
console.log(`mock ProPresenter looks on http://127.0.0.1:${port} — live look changes every ${every}s`);
console.log(`live look → ${saved[live].id.name}`);
});
+14 -1
View File
@@ -212,7 +212,8 @@ export default function create({ root, moduleApi }) {
}
function renderLive() {
liveEl.classList.remove('is-live', 'is-down');
liveEl.classList.remove('is-live', 'is-down', 'is-look', 'is-modified');
liveEl.removeAttribute('title');
if (feedOffline) {
liveEl.classList.add('is-down');
liveTextEl.textContent = 'Feed offline';
@@ -231,6 +232,18 @@ export default function create({ root, moduleApi }) {
}
if (state.reachable) {
liveEl.classList.add('is-live');
const lookName = state.look && state.look.name;
if (lookName) {
// The audience look replaces "Live". textContent, never innerHTML:
// the name is whatever someone typed into ProPresenter.
const modified = Boolean(state.look.modified);
liveEl.classList.add('is-look');
liveEl.classList.toggle('is-modified', modified);
liveEl.title = modified ? 'Audience look (modified)' : 'Audience look';
liveTextEl.textContent = lookName;
moduleApi.setStatus('ok', `Live · ${lookName}`);
return;
}
liveTextEl.textContent = 'Live';
moduleApi.setStatus('ok', 'Following ProPresenter');
return;
+2 -2
View File
@@ -1,7 +1,7 @@
{
"id": "propresenter-now-next",
"name": "ProPresenter",
"version": "1.1.1",
"version": "1.2.0",
"proddash": ">=1.5.0",
"description": "What is live in ProPresenter and what comes next",
"permissions": [
@@ -50,7 +50,7 @@
},
"showLive": {
"type": "switch",
"label": "Live status",
"label": "Audience look",
"default": true,
"group": "Show"
},
+164 -23
View File
@@ -10,6 +10,12 @@
*
* GET /state → { state: <view-model> } (instant paint for new tiles)
* GET /stream → SSE, `state` events (change-detected, heartbeated)
*
* The view-model carries `look`: the live audience look as
* { name, modified }, or null while it is unknown. A small poller of this
* file's own reads it (GET /v1/look/current, with /v1/looks to name it) from
* the same ProPresenter the core follows. Its failures only null `look`; they
* never touch the core's reachable / lastError.
*/
const { createProPresenterClient, clearedViewModel } = require('./propresenter-core');
@@ -17,6 +23,17 @@ const { createProPresenterClient, clearedViewModel } = require('./propresenter-c
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
const HEARTBEAT_MS = 15000;
const LOOK_POLL_MS = 1000; // the look matters the moment it changes
const LOOK_RETRY_MS = 3000; // while the look cannot be read
const LOOKS_EVERY = 10; // re-read the saved looks every N polls
const LOOK_TIMEOUT_MS = 2500;
const LOOK_FAILS_BEFORE_NULL = 3; // consecutive failed reads before the look is called unknown
/** One look's name, trimmed — ProPresenter leaves the current look's name blank on some versions. */
function lookName(entry) {
return String(entry?.id?.name ?? entry?.name ?? '').trim();
}
/** Set by init(), read by the routes — both are rebuilt together on remount. */
let current = null;
@@ -31,30 +48,38 @@ module.exports = {
const port = Number(config.port ?? ep.port) || 1025;
const enabled = Boolean(host && port);
let latest = clearedViewModel();
let look = null; // { name, modified } or null while unknown
let lastFrame = '';
/** The core's view-model plus the live look — what every tile and GET /state sees. */
const snapshot = () => ({ ...latest, look });
function broadcast() {
if (!streams.size) return;
let frame;
try {
frame = JSON.stringify(snapshot());
} catch {
return;
}
// The poll loop emits every tick whether anything changed or not.
// Broadcasting only real changes keeps idle tiles genuinely idle.
if (frame === lastFrame) return;
lastFrame = frame;
const payload = `event: state\ndata: ${frame}\n\n`;
for (const res of streams) {
try {
res.write(payload);
} catch {
streams.delete(res);
}
}
}
const client = createProPresenterClient({
onData(view) {
latest = view;
if (!streams.size) return;
let frame;
try {
frame = JSON.stringify(view);
} catch {
return;
}
// The poll loop emits every tick whether anything changed or not.
// Broadcasting only real changes keeps idle tiles genuinely idle.
if (frame === lastFrame) return;
lastFrame = frame;
const payload = `event: state\ndata: ${frame}\n\n`;
for (const res of streams) {
try {
res.write(payload);
} catch {
streams.delete(res);
}
}
broadcast();
},
});
@@ -65,8 +90,122 @@ module.exports = {
password: String(config.password || ''),
});
/* ── audience look ──────────────────────────────────────────────
Same reads and rules as the ProPresenter Look module. ProPresenter
sometimes leaves the live look's name blank, so the saved list is
consulted: the look's `index` points at the saved look it matches, or
is past the end when someone has changed it by hand. */
const password = String(config.password || '');
let saved = []; // names of the saved looks, in ProPresenter's order
let savedOk = false; // the list exists and is in `saved`
let savedTried = false; // /v1/looks has answered (a 404 counts: do not ask again every poll)
let lookTick = 0;
let lookFails = 0; // consecutive failed reads of the look
let lookStopped = false;
let lookTimer = null;
function lookUrl(target) {
const built = new URL(`http://${host}:${port}${target}`);
if (password) built.searchParams.set('password', password);
return built;
}
async function lookJson(target) {
const response = await fetch(lookUrl(target), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(LOOK_TIMEOUT_MS),
});
if (!response.ok) {
const err = new Error(`ProPresenter ${response.status} ${response.statusText}`);
err.status = response.status;
throw err;
}
const text = await response.text();
return text ? JSON.parse(text) : null;
}
async function refreshSaved() {
try {
const list = await lookJson('/v1/looks');
// A real empty array is a valid (empty) list. Anything else that
// answered 200 is not a list, so it names nothing — an empty list
// would call every look modified.
saved = Array.isArray(list) ? list.map(lookName) : [];
savedOk = Array.isArray(list);
savedTried = true;
} catch (err) {
// An older ProPresenter without the list still answers look/current;
// the live look's own name is shown then. Remembered, so the list is
// asked for again only on the normal cadence, not on every poll.
if (err.status !== 404) throw err;
saved = [];
savedOk = false;
savedTried = true;
}
}
/** { name, modified }, or null when there is no name to show (the pill then says "Live"). */
function resolveLook(live) {
const index = Number(live?.id?.index);
const own = lookName(live);
if (savedOk && Number.isInteger(index) && index >= 0 && index < saved.length) {
return { name: saved[index] || own || `Look ${index + 1}`, modified: false };
}
// Changed by hand, matching no saved look.
if (savedOk && Number.isInteger(index) && index >= saved.length) {
return { name: own || 'Custom look', modified: true };
}
// No saved list to name it from: the live look's own name, or nothing.
return own ? { name: own, modified: false } : null;
}
function setLook(next) {
if (JSON.stringify(next) === JSON.stringify(look)) return;
look = next;
broadcast();
}
async function pollLook() {
if (lookStopped) return;
let next = null;
let readOk = false;
try {
lookTick += 1;
if (lookTick % LOOKS_EVERY === 1 || !savedTried) await refreshSaved();
const live = await lookJson('/v1/look/current');
if (!live || typeof live !== 'object') throw new Error('no look in the answer');
next = resolveLook(live);
// A look saved since our last read: refresh the list now rather than
// calling it "Custom" for up to ten seconds.
if (next?.modified && !look?.modified && lookTick % LOOKS_EVERY !== 1) {
await refreshSaved();
next = resolveLook(live);
}
readOk = true;
} catch {
// A list that exists may have changed while ProPresenter was away:
// re-read it first when it comes back. One that is known not to exist
// (404) is not asked for again on every failed retry.
if (savedOk) lookTick = 0;
}
if (lookStopped) return;
if (readOk) {
lookFails = 0;
setLook(next);
} else {
// One slow or dropped read must not blank the pill: the last good look
// stands until the reads have failed several times running. A real
// outage shows as "Reconnecting…" through the core, not as this name.
lookFails += 1;
if (lookFails >= LOOK_FAILS_BEFORE_NULL) setLook(null);
}
lookTimer = setTimeout(pollLook, readOk ? LOOK_POLL_MS : LOOK_RETRY_MS);
}
if (enabled) {
client.start();
pollLook();
log(`following ProPresenter at ${host}:${port}`);
} else {
log('no ProPresenter host configured — set one in /admin');
@@ -83,10 +222,12 @@ module.exports = {
}, HEARTBEAT_MS);
heartbeat.unref?.();
current = { streams, getLatest: () => latest };
current = { streams, getLatest: snapshot };
return {
stop() {
lookStopped = true;
clearTimeout(lookTimer);
clearInterval(heartbeat);
client.stop();
for (const res of streams) {
@@ -135,7 +276,7 @@ module.exports = {
instanceSchema: {
cleanTitles: { type: 'switch', label: 'Tidy titles', default: true },
showPlaylist: { type: 'switch', label: 'Playlist', default: true, group: 'Show' },
showLive: { type: 'switch', label: 'Live status', default: true, group: 'Show' },
showLive: { type: 'switch', label: 'Audience look', default: true, group: 'Show' },
showNowLabel: { type: 'switch', label: 'Now label', default: true, group: 'Show' },
showSlideCount: { type: 'switch', label: 'Slide count', default: true, group: 'Show' },
showProgress: { type: 'switch', label: 'Progress bar', default: true, group: 'Show' },
@@ -149,7 +290,7 @@ module.exports = {
return {
'GET /state': (req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ state: current ? current.getLatest() : clearedViewModel() }));
res.end(JSON.stringify({ state: current ? current.getLatest() : { ...clearedViewModel(), look: null } }));
},
'GET /stream': (req, res) => {
@@ -160,7 +301,7 @@ module.exports = {
'X-Accel-Buffering': 'no',
});
res.write('retry: 3000\n\n');
const view = current ? current.getLatest() : clearedViewModel();
const view = current ? current.getLatest() : { ...clearedViewModel(), look: null };
res.write(`event: state\ndata: ${JSON.stringify(view)}\n\n`);
if (!current) return void res.end();
const { streams } = current;
+18
View File
@@ -141,6 +141,24 @@
transition: opacity 0.6s ease 1.4s;
}
/* The audience look is information, not a status chip: it stays, in the
name as typed, in the normal text colour. A look changed by hand (matching
no saved look) is set in italic. */
[data-module="propresenter-now-next"] .nn-live.is-live.is-look .nn-live-text {
opacity: 1;
transition: none;
max-width: 14em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-transform: none;
color: var(--text);
font-weight: 600;
}
[data-module="propresenter-now-next"] .nn-live.is-look.is-modified .nn-live-text {
font-style: italic;
}
/* ── NOW card ──
FIXED height so the title box is deterministic and the NOW/NEXT layout
cannot oscillate (see the wall display's notes). */
+3 -1
View File
@@ -160,7 +160,7 @@ export default function create({ root, moduleApi }) {
el.sources.hidden = !on('showSources') || !s.sources.length;
if (!el.sources.hidden) {
el.sources.innerHTML = s.sources.map((x) => `
<span class="vc-src${x.connected ? ' is-on' : ''}" title="${esc(x.connected ? `${x.name || x.label} · ${x.address}` : (x.error || 'Joining…'))}">
<span class="vc-src${x.connected ? ' is-on' : ''}${x.offNetwork ? ' is-off-network' : ''}" title="${esc((x.offNetwork ? `${x.offNetwork === 'by name' ? 'Set by name' : 'Not on the wired network'} (${s.wiredNetwork}) · ` : '') + (x.connected ? `${x.name || x.label} · ${x.address}` : (x.error || 'Joining…')))}">
<span class="vc-dot"></span>${esc(x.label)}
</span>`).join('');
}
@@ -186,6 +186,8 @@ export default function create({ root, moduleApi }) {
if (!s.listening) return moduleApi.setStatus('error', s.error || 'Not listening for MIDI');
const connected = s.sources.filter((x) => x.connected);
const waiting = s.sources.filter((x) => !x.connected);
const off = s.sources.filter((x) => x.offNetwork);
if (off.length) return moduleApi.setStatus('error', `${off.map((x) => x.label).join(', ')} not on the wired network (${s.wiredNetwork})`);
if (!connected.length) {
const why = waiting.length ? `Joining ${waiting.map((x) => `${x.label}${x.error ? ` (${x.error})` : ''}`).join(', ')}` : 'No MIDI session connected';
return moduleApi.setStatus('error', why);
+8 -1
View File
@@ -1,7 +1,7 @@
{
"id": "vista-cues",
"name": "Vista Cues",
"version": "1.0.1",
"version": "1.1.0",
"proddash": ">=1.5.0",
"description": "Which Vista 3 cue lists are live: hears the network MIDI that ProPresenter and Tracks send Vista",
"permissions": [
@@ -131,6 +131,13 @@
"default": true,
"group": "This machine",
"help": "List this machine in Audio MIDI Setup's Directory on the network (macOS)."
},
"wiredNetwork": {
"type": "string",
"label": "Wired network",
"default": "",
"group": "This machine",
"help": "The network MIDI must come over, like 10.1.0.0/16 (several: comma between). A sender set by name or to an address outside it shows red here and on the tile; it still joins. Empty: any network."
}
},
"configGroups": {
+58 -1
View File
@@ -49,6 +49,44 @@ function emptyState(extra = {}) {
};
}
/**
* The wired network(s) MIDI must ride ("10.1.0.0/16, 10.2.0.0/16"). The Macs
* here are also on Wi-Fi and the Dante networks, and Bonjour lists each
* session once per network, so a sender typed or picked from the wrong copy
* works — until Wi-Fi hiccups mid-service. Returns [{ base, bits, text }];
* throws on a typo so the admin page says so at once.
*/
function parseNets(text) {
return String(text || '').split(/[\s,]+/).filter(Boolean).map((n) => {
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/.exec(n);
const parts = m ? m.slice(1, 5).map(Number) : [];
const bits = m && m[5] !== undefined ? Number(m[5]) : 32;
if (!m || parts.some((x) => x > 255) || bits > 32) {
throw new Error(`Wired network: "${n}" is not a network like 10.1.0.0/16`);
}
return { base: ipNumber(parts.join('.')), bits, text: n };
});
}
function ipNumber(a) {
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(String(a || '').trim());
return m ? m.slice(1, 5).reduce((n, x) => n * 256 + Number(x), 0) : null;
}
/**
* Why an address is not on the wired network: '' when it is (or there is no
* rule), 'by name' for a host name (it resolves to whichever network answers
* first), 'not wired' for an address outside every wired network.
*/
function offNetwork(nets, address) {
if (!nets.length || !address) return '';
const v = ipNumber(address);
if (v === null) return 'by name';
if (address.startsWith('127.')) return '';
const span = (bits) => 2 ** (32 - bits);
return nets.some((n) => Math.floor(v / span(n.bits)) === Math.floor(n.base / span(n.bits))) ? '' : 'not wired';
}
function endpointOf(v) {
const ep = v && typeof v === 'object' ? v : {};
return { host: String(ep.host || '').trim(), port: Number(ep.port) || 5004 };
@@ -62,6 +100,8 @@ module.exports = {
const exclusive = config.exclusive === true;
const headTakesOver = config.headTakesOver !== false;
const { rules, errors: mappingErrors, groups } = parseMapping(config.cueLists, { middleC });
const nets = parseNets(config.wiredNetwork);
const netText = nets.map((n) => n.text).join(', ');
if (mappingErrors.length) {
for (const e of mappingErrors) log(`cue lists line ${e.line} skipped (${e.why}): ${e.text}`);
}
@@ -187,6 +227,9 @@ module.exports = {
label: s.label,
name: p ? p.name : '',
address: inv?.address || s.host,
// A name is judged as typed: it may resolve to Wi-Fi next time even if it is wired now.
offNetwork: offNetwork(nets, s.host) || offNetwork(nets, inv?.address || ''),
host: s.host,
configured: true,
connected: Boolean(p),
error: p ? '' : (inv?.error || ''),
@@ -195,7 +238,7 @@ module.exports = {
}
for (const p of session.participants.values()) {
if (seen.has(p.key) || !p.connected) continue;
out.push({ label: p.label || p.name, name: p.name, address: p.address, configured: false, connected: true, error: '', lastHeard: p.lastHeard });
out.push({ label: p.label || p.name, name: p.name, address: p.address, offNetwork: offNetwork(nets, p.address), configured: false, connected: true, error: '', lastHeard: p.lastHeard });
}
return out;
}
@@ -219,6 +262,7 @@ module.exports = {
sessionName,
port,
advertised,
wiredNetwork: netText,
sources: sources(),
current: cur || null,
// The rest in sheet order of their group, so a colour chip stays put as it changes.
@@ -304,6 +348,16 @@ module.exports = {
health() {
if (session.error) return { status: 'error', message: session.error };
const src = sources();
// Off the wired network: still joined (the cues keep working), but said first and in red.
const off = src.filter((s) => s.offNetwork);
if (off.length) {
return {
status: 'error',
message: `${off.map((s) => (s.offNetwork === 'by name'
? `${s.label} is set by name (${s.host})`
: `${s.label} ${s.configured ? `(${s.address})` : `at ${s.address}`} is not on the wired network`)).join(' · ')}: use its address on ${netText}`,
};
}
const on = src.filter((s) => s.connected);
const lines = mappingErrors.length ? ` · ${mappingErrors.length} cue-list line${mappingErrors.length === 1 ? '' : 's'} not understood (first: line ${mappingErrors[0].line})` : '';
const map = rules.length ? '' : ' · no cue lists mapped yet';
@@ -323,6 +377,9 @@ module.exports = {
};
},
// For the tests.
_offNetwork: (text, address) => offNetwork(parseNets(text), address),
/**
* One picker entry, so the + menu gives Vista Cues a heading of its own —
* a module with no list sits headless under whichever module precedes it
+2
View File
@@ -180,6 +180,8 @@
}
[data-module="vista-cues"] .vc-src.is-on { color: var(--text); }
[data-module="vista-cues"] .vc-src.is-on .vc-dot { background: var(--accent); }
[data-module="vista-cues"] .vc-src.is-off-network { color: var(--danger); }
[data-module="vista-cues"] .vc-src.is-off-network .vc-dot { background: var(--danger); }
@container (max-height: 140px) {
[data-module="vista-cues"] .vc-sources { display: none; }
}
+47
View File
@@ -363,6 +363,53 @@ async function freePair() {
await wait(80);
});
await test('wired network: addresses outside it, and names, are flagged', () => {
const mod = require('./server');
const off = mod._offNetwork;
assert.strictEqual(off('', '10.6.9.193'), ''); // no rule: any network
assert.strictEqual(off('10.1.0.0/16', '10.1.3.227'), '');
assert.strictEqual(off('10.1.0.0/16', '10.6.9.193'), 'not wired'); // Wi-Fi
assert.strictEqual(off('10.1.0.0/16', '169.254.12.3'), 'not wired'); // Dante
assert.strictEqual(off('10.1.0.0/16', 'NA-Tracks.local'), 'by name');
assert.strictEqual(off('10.1.0.0/16', '127.0.0.1'), ''); // Companion on this machine
assert.strictEqual(off('10.1.0.0/16, 10.6.0.0/20', '10.6.9.193'), ''); // several
assert.strictEqual(off('10.1.3.227', '10.1.3.227'), ''); // a single address
assert.throws(() => off('wifi', '10.1.3.227'), /not a network like 10\.1\.0\.0\/16/);
assert.throws(() => off('10.1.0.0/40', '10.1.3.227'), /not a network/);
});
await test('server entry: a sender off the wired network still joins, and says so', async () => {
const senderPort = await freePair();
const sender = await fakeSender(senderPort, 'Fake Tracks');
const port = await freePair();
const mod = require('./server');
assert.throws(() => mod.init({ config: { sessionPort: port, advertise: false, wiredNetwork: '10.1/16' }, log: () => {} }), /Wired network/);
const handle = mod.init({
config: {
sessionPort: port,
advertise: false,
wiredNetwork: '10.1.0.0/16',
source2Name: 'Tracks',
source2: { host: 'localhost', port: senderPort }, // a name: it may resolve to Wi-Fi next time
cueLists: '60 = Walk-in',
},
log: () => {},
});
await until(() => sender.peers.length > 0); // joined all the same
const h = handle.health();
assert.strictEqual(h.status, 'error');
assert.match(h.message, /^Tracks is set by name \(localhost\): use its address on 10\.1\.0\.0\/16$/);
const routes = mod.routes({});
let body = '';
await new Promise((r) => routes['GET /state']({}, { writeHead() {}, end(b) { body = b; r(); } }));
const st = JSON.parse(body).state;
assert.strictEqual(st.wiredNetwork, '10.1.0.0/16');
assert.strictEqual(st.sources[0].offNetwork, 'by name');
handle.stop();
sender.close();
await wait(80);
});
console.log(`\n${passed} passed`);
process.exit(0);
})().catch((e) => {
+255
View File
@@ -0,0 +1,255 @@
/**
* Mock ProPresenter slide status + mock Overflow Dashboard API, for developing
* the overflow-tap module without a live ProPresenter or an Overflow account.
*
* node tools/overflow-tap-mock.js [--pp=1602] [--overflow=24720] [--step=4000]
* [--login=<email>:<password>] [--expire=<seconds>]
* [--fail-switch] [--slow] [--no-announcements]
*
* ProPresenter side (127.0.0.1:1602 by default) — the routes the module uses,
* with a service that advances every --step ms:
* GET /version reachability probe
* GET /v1/status/slide { current: { uuid, text, notes }, next }, cycling
* through slides whose notes carry keywords
* ("give", "Next steps: connect", "prayer"),
* slides with other notes ("forgiveness" —
* must NOT fire "give"), a slide whose
* keyword is not mapped on the account, and
* a stretch with nothing live.
* GET /v1/announcement/slide_index the announcement layer: a three-slide
* document whose index advances every third
* step; its middle slide carries "events".
* GET /v1/announcement/active that document, groups → slides → notes.
* --no-announcements answers 404 to both,
* like an older ProPresenter.
*
* Overflow side (127.0.0.1:24720 by default; run ProdDash with
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720) — the Dashboard API the
* module speaks (see modules/overflow-tap/overflow.js):
* POST /nonprofit-dashboard/authentication/login/jwt any non-empty email +
* password signs in (--login=<email>:<password> demands that pair,
* answering 401 otherwise); the token expires after --expire seconds
* (3600 by default — set 20 to exercise the sign-in-again path). An
* @overflow.co email gets a staff token with no nonprofit of its own.
* GET /nonprofit-dashboard/propresenter/nonprofits staff: the nonprofits
* POST /nonprofit-dashboard/propresenter/nonprofits/{id}/select
* GET /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords four mappings:
* give → Give Online (every group), connect → Connect Card (Lobby),
* prayer → Prayer Request (every group), events → Events (Lobby, Kids
* Wing). "baptism" is deliberately unmapped.
* POST /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords?syncNonprofit=true
* { keywords } — moves the groups and answers the updated list.
* --fail-switch answers 500 instead, to exercise the failure path.
* Every signed-in call needs a Bearer token this mock issued and has not
* expired, or it answers 401. --slow adds a two-second delay to every Overflow
* answer.
*
* Every incoming request is logged so we can confirm what the module does.
*/
'use strict';
const http = require('http');
const crypto = require('crypto');
const args = process.argv.slice(2);
const opt = (name, dflt) => {
const a = args.find((x) => x.startsWith(`--${name}=`));
return a ? a.slice(name.length + 3) : dflt;
};
const PP_PORT = Number.parseInt(opt('pp', '1602'), 10);
const OF_PORT = Number.parseInt(opt('overflow', '24720'), 10);
const STEP_MS = Number.parseInt(opt('step', '4000'), 10);
const LOGIN = opt('login', '');
const EXPIRE_S = Number.parseInt(opt('expire', '3600'), 10);
const FAIL_SWITCH = args.includes('--fail-switch');
const SLOW = args.includes('--slow');
const NO_ANN = args.includes('--no-announcements');
function sendJson(res, code, body, extra = {}) {
res.writeHead(code, { 'Content-Type': 'application/json', ...extra });
res.end(body === undefined ? '' : JSON.stringify(body));
}
/* ── ProPresenter ────────────────────────────────────────────────── */
const SLIDES = [
{ uuid: 'S1', text: 'Welcome to Waters Church', notes: '' },
{ uuid: 'S2', text: 'Give online', notes: 'give' },
{ uuid: 'S3', text: 'Amazing grace, how sweet the sound', notes: 'Chorus x2' },
{ uuid: 'S4', text: 'Fill out a Connect Card', notes: 'Next steps: connect' },
{ uuid: 'S5', text: 'A sermon on forgiveness', notes: 'forgiveness — do not switch on this' },
{ uuid: 'S6', text: 'Pray with us', notes: 'prayer' },
{ uuid: 'S7', text: 'Baptism Sunday', notes: 'baptism' },
null, // nothing live
{ uuid: 'S8', text: 'Give online (again)', notes: 'give' },
{ uuid: 'S9', text: 'See you next week', notes: '' },
];
const ANNOUNCEMENT = {
id: { uuid: 'ANN-1', name: 'Announcements', index: 0 },
presentation_path: 'Announcements',
groups: [
{ name: 'Loop', slides: [
{ enabled: true, notes: '', text: 'Welcome', label: '' },
{ enabled: true, notes: 'events', text: 'Upcoming events', label: '' },
{ enabled: true, notes: '', text: 'Thanks for coming', label: '' },
] },
],
};
let step = 0;
setInterval(() => { step += 1; }, STEP_MS).unref();
const slideIdx = () => step % SLIDES.length;
const annIdx = () => Math.floor(step / 3) % ANNOUNCEMENT.groups[0].slides.length;
function slidePayload() {
const cur = SLIDES[slideIdx()];
const nxt = SLIDES[(slideIdx() + 1) % SLIDES.length];
return {
current: cur ? { uuid: cur.uuid, text: cur.text, notes: cur.notes } : null,
next: nxt ? { uuid: nxt.uuid, text: nxt.text, notes: nxt.notes } : null,
};
}
http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const p = url.pathname;
console.log(`[pp] ${req.method} ${p}`);
if (p === '/version') return sendJson(res, 200, { name: 'Overflow mock', platform: 'mac', host_description: 'ProPresenter 21 (mock)', api_version: 'v1' });
if (p === '/v1/status/slide') return sendJson(res, 200, slidePayload());
if (p === '/v1/status/layers') return sendJson(res, 200, { slide: Boolean(SLIDES[slideIdx()]), media: false, announcements: !NO_ANN });
if (p === '/v1/announcement/slide_index') {
if (NO_ANN) return sendJson(res, 404, { error: 'not found' });
return sendJson(res, 200, { announcement_index: { index: annIdx(), presentation_id: ANNOUNCEMENT.id } });
}
if (p === '/v1/announcement/active') {
if (NO_ANN) return sendJson(res, 404, { error: 'not found' });
return sendJson(res, 200, { announcement: ANNOUNCEMENT });
}
sendJson(res, 404, { error: 'not found' });
}).listen(PP_PORT, '127.0.0.1', () => {
console.log(`[pp] mock ProPresenter on http://127.0.0.1:${PP_PORT} (slide every ${STEP_MS} ms${NO_ANN ? ', no announcement layer' : ''})`);
});
/* ── Overflow Dashboard ──────────────────────────────────────────── */
const NONPROFIT = { id: '6710f34fd5061afeec3eab00', name: 'Waters Church (mock)', slug: 'waters-church' };
const DESTINATIONS = {
give: { id: '6710f34fd5061afeec3eab01', name: 'Give Online' },
connect: { id: '6710f34fd5061afeec3eab02', name: 'Connect Card' },
prayer: { id: '6710f34fd5061afeec3eab03', name: 'Prayer Request' },
events: { id: '6710f34fd5061afeec3eab04', name: 'Events' },
};
const GROUPS = [
{ id: '6710f34fd5061afeec3eab11', tapGroupName: 'Auditorium', destinationId: DESTINATIONS.events.id },
{ id: '6710f34fd5061afeec3eab12', tapGroupName: 'Lobby', destinationId: DESTINATIONS.events.id },
{ id: '6710f34fd5061afeec3eab13', tapGroupName: 'Kids Wing', destinationId: DESTINATIONS.connect.id },
];
const groupRef = (g) => ({ id: g.id, tapGroupName: g.tapGroupName });
const KEYWORDS = [
{ id: 'kw1', keyword: 'give', destinationId: DESTINATIONS.give.id, destinationName: DESTINATIONS.give.name, tapGroups: GROUPS.map(groupRef), active: true },
{ id: 'kw2', keyword: 'connect', destinationId: DESTINATIONS.connect.id, destinationName: DESTINATIONS.connect.name, tapGroups: [groupRef(GROUPS[1])], active: true },
{ id: 'kw3', keyword: 'prayer', destinationId: DESTINATIONS.prayer.id, destinationName: DESTINATIONS.prayer.name, tapGroups: GROUPS.map(groupRef), active: true },
{ id: 'kw4', keyword: 'events', destinationId: DESTINATIONS.events.id, destinationName: DESTINATIONS.events.name, tapGroups: [groupRef(GROUPS[1]), groupRef(GROUPS[2])], active: true },
];
const stampedKeywords = () => KEYWORDS.map((k) => ({ ...k, nonprofitId: NONPROFIT.id, updatedAt: new Date().toISOString() }));
const tokens = new Map(); // token → { email, exp, nonprofitId }
function issueToken(email) {
const staff = email.toLowerCase().endsWith('@overflow.co');
const now = Math.floor(Date.now() / 1000);
const payload = {
id: 'user-1',
email,
fullName: 'Mock User',
role: staff ? 'overflowSupport' : 'orgAdmin',
permissions: [{ action: 'manage', subject: 'TAP' }],
nonprofits: staff ? [] : [{ id: NONPROFIT.id, name: NONPROFIT.name, slug: NONPROFIT.slug }],
iat: now,
exp: now + EXPIRE_S,
};
if (!staff) payload.nonprofitId = NONPROFIT.id;
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const token = `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}.${crypto.randomBytes(16).toString('base64url')}`;
tokens.set(token, { email, exp: payload.exp, nonprofitId: staff ? '' : NONPROFIT.id });
return token;
}
function bearer(req) {
const m = /^Bearer\s+(.+)$/.exec(String(req.headers.authorization || ''));
if (!m) return null;
const t = tokens.get(m[1]);
if (!t) return null;
if (Date.now() / 1000 >= t.exp) return null;
return t;
}
function readBody(req) {
return new Promise((resolve) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
try { resolve(JSON.parse(body || '{}')); } catch { resolve(null); }
});
});
}
http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const p = url.pathname;
const answer = (code, body) => {
const go = () => sendJson(res, code, body);
if (SLOW) setTimeout(go, 2000);
else go();
};
console.log(`[overflow] ${req.method} ${p}${url.search}`);
if (req.method === 'POST' && p === '/nonprofit-dashboard/authentication/login/jwt') {
const body = await readBody(req);
const email = String((body && body.email) || '').trim();
const password = String((body && body.password) || '');
if (!email || !password) return answer(400, { statusCode: 400, message: 'email and password are required' });
if (LOGIN && `${email}:${password}` !== LOGIN) return answer(401, { statusCode: 401, message: 'Invalid email or password' });
console.log(`[overflow] signed in ${email} (token expires in ${EXPIRE_S} s)`);
return answer(200, { token: issueToken(email) });
}
const session = bearer(req);
if (!session) return answer(401, { statusCode: 401, message: 'Unauthorized' });
if (req.method === 'GET' && p === '/nonprofit-dashboard/propresenter/nonprofits') {
return answer(200, { data: { nonprofits: [NONPROFIT] } });
}
const sel = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/select$/.exec(p);
if (req.method === 'POST' && sel) {
if (sel[1] !== NONPROFIT.id) return answer(404, { statusCode: 404, message: 'Nonprofit not found' });
session.nonprofitId = NONPROFIT.id;
return answer(204);
}
const kw = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/keywords$/.exec(p);
if (kw) {
if (kw[1] !== NONPROFIT.id || session.nonprofitId !== NONPROFIT.id) return answer(403, { statusCode: 403, message: 'Forbidden' });
if (req.method === 'GET') return answer(200, stampedKeywords());
if (req.method === 'POST') {
const body = await readBody(req);
const keys = body && Array.isArray(body.keywords) ? body.keywords.map((k) => String(k).toLowerCase().trim()) : [];
if (!keys.length) return answer(400, { statusCode: 400, message: 'keywords is required' });
if (FAIL_SWITCH) return answer(500, { statusCode: 500, message: 'Internal server error' });
for (const k of keys) {
const entry = KEYWORDS.find((x) => x.keyword === k);
if (!entry) {
console.log(`[overflow] "${k}" is not a mapped keyword — ignored`);
continue;
}
for (const g of entry.tapGroups) {
const group = GROUPS.find((x) => x.id === g.id);
if (group) group.destinationId = entry.destinationId;
}
console.log(`[overflow] ${entry.tapGroups.map((g) => g.tapGroupName).join(', ')} → ${entry.destinationName} (${k})`);
}
return answer(200, stampedKeywords());
}
}
answer(404, { statusCode: 404, message: `Cannot ${req.method} ${p}` });
}).listen(OF_PORT, '127.0.0.1', () => {
console.log(`[overflow] mock Overflow Dashboard API on http://127.0.0.1:${OF_PORT}${FAIL_SWITCH ? ' (every switch answers 500)' : ''}${LOGIN ? ' (one login accepted)' : ''}${EXPIRE_S < 3600 ? ` (tokens expire after ${EXPIRE_S} s)` : ''}`);
});