Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Fable 5.1 a7e40ee1a8 Layouts keep every tile they are given, and show what is on offer
The bug behind this: for seventeen days the server rebuilt each saved tile
from the fields it knew and dropped the rest — including `variant`, so a
layout of five different timers came back from the server as five "All
timers". The client had carried `variant` since multi-tile modules arrived;
the server was the one that lagged, and it is the part of ProdDash that
always will (a booth updates weeks after the dev machine). So the server
stops deciding which tile fields exist: it checks the shape of the ones it
knows, keeps any other small plain-JSON field a tile carries, drops only
retired ones, stamps the file `v: 2`, and refuses a layout over a hundred
tiles instead of quietly saving the first hundred. A test round-trips a
fully-populated tile plus one the server has never heard of, and fails if
anything is lost.

Then the rule the arrangement is loaded by. A layout keeps every tile it is
given; the board shows the ones whose module is installed and enabled and —
for a tile added from a picker entry — whose entry is offered right now. The
rest keep their place and draw nothing, and appear the moment the registry
offers them again: a Service timer waits for PCO Plan, a transcript for its
channel. Never the module's default in their place. Because a module's entry
list can change with no event to mark it (PCO Plan comes back and, seconds
later, the timers module is offering its timers again), the registry is
re-read every few seconds while anything is folded away, and not at all
otherwise. The timers module's placeholder card for a missing solo timer
goes with this: nothing is drawn, the dot says why.

Loading says what it did — "Loaded 'FOH': 9 tiles, 2 not shown — Service,
#ops aren't offered right now" — with Undo in the message. Saving over a
name asks first, and the server keeps the copy it replaced one back; Admin →
Saved layouts gets Restore previous, which swaps the two so a restore is one
restore from undone. The browser keeps one good copy of its own layout
behind the live one and falls back to it when the live one will not parse.

Shell 1.8.0, timers 1.4.1. The layouts already saved on the booth machines
had their variants stripped before this and have to be saved again from a
browser that still holds the right tiles — nothing here recovers what the
files no longer contain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 12:02:34 -04:00
michael c075655817 Merge pull request 'An update that goes wrong is no longer the end of it' (#11) from claude/update-safety into main 2026-09-17 16:30:42 -04:00
michael 48d0f5a04a Merge pull request 'SPL Meter: measure the room, not just watch it' (#10) from claude/spl-meter-module into main 2026-09-17 16:18:11 -04:00
Waters ChurchandClaude Opus 5 12f6e22187 An update that goes wrong is no longer the end of it
Two things the audit found, both about the archive path every shipped .app
uses.

**Settings could be wiped.** The update kept config/modules.json and
config/layouts/ by name, but not config/proddash.json — which holds the admin
PIN, the theme and the forge pointer. On an install whose data directory IS
the app's own config/ (an unwritable Application Support, or PRODDASH_DATA_DIR
aimed inside the app), an update wrote the repo's shipped defaults over it:
the theme went, and admin silently unlocked. Nothing inside the data
directory is written now, wherever it happens to sit, and the two legacy
names stay listed for an install whose state never moved out.

**A bad update was unrecoverable.** No rollback, and a fresh .app would not
help: seeding only replaces an *older* copy, and a broken one carries a
higher version — so the fix was deleting a folder nothing told you about.
Three changes, in the order they catch things:

- The download must parse before anything is swapped in. `node --check`
  against the new server.js, and a failure is reported with the syntax error
  and leaves the running copy untouched.
- Past that, the copy being replaced is set aside as `app.previous`, and
  dropped once the new version has stayed up ninety seconds — so that folder
  existing means "the last update has not proven itself yet", and never
  anything older.
- The launcher, which already gives up after four stops in two minutes, now
  looks for that folder before it does and puts the old copy back. Once: if
  the old one will not run either, the update was not the problem.

The launcher half only reaches a machine with a new .app; the server half
travels by the normal update.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:16:46 -04:00
Waters ChurchandClaude Opus 5 87929bfd0b Don't take the server down when the module unmounts
Deploying 0.3.0 to AB crashed the ProdDash server on restart:

  TypeError: Cannot read properties of null (reading 'streams')
      at server.js:273  req.on('close', () => state.streams.delete(res))

teardown() nulls `state` and then ends every open stream, which fires each
request's close handler — and the handler reached back through the `state`
it had just discarded. Any re-init with a browser connected would do it, so
saving admin config while a dashboard was open was enough to stop the whole
server, every module with it.

The stream handler now closes over this mount's Set rather than the module's
state: after teardown it deletes from a set nobody reads, which is harmless.
The same hazard existed anywhere a callback could outlive the mount — the
keepalive ping, a frame arriving from the listener mid-teardown, and the
/measure interval — and each is closed over or guarded now.

Reproduced first (open an SSE client, save config, watch it fall over) and
confirmed fixed against the same sequence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:06:47 -04:00
Waters ChurchandClaude Opus 5 46d44d494b Keep the banding caveat where the band plan is built
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:00:34 -04:00
Waters ChurchandClaude Opus 5 44fd0b10ba SPL Meter 0.3.0: bring the tiles to the interface rules
Rebasing onto main landed rules 5 to 11, which this module predates and
broke in three places.

Rule 5 — no explanatory text in a tile's gear: the Banding and Smoothing
fields each carried a paragraph. The Banding one said something true and
worth knowing, that below roughly 200 Hz the window length limits real
resolution however fine the setting, so it moves to the source where the
band plan is built rather than being lost. Option labels shortened with it.

Rule 11 — one control per action: the spectrogram offered its axis both as
a gear field and as a title-bar button. The button stays, since that is
what was asked for, and the field is gone. The setting still persists with
the tile; saveInstanceSettings keeps keys that no schema declares.

Rule 9 — title-bar controls are icons: the axis button was drawing a "⇆"
as a label. It is an inline SVG now, with the tooltip carrying the words
and naming the current direction rather than the action.

Rule 8 also asks that a tile can switch off what it draws, so the readout
gained a Caption toggle and both graphs gained Labels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:00:17 -04:00
Waters ChurchandClaude Opus 5 543795f9fd Let the SPL readout fill its tile
The number was capped at 6rem, so a readout given half a wall stopped
growing at the size it reached in a small tile.

Removing the cap was not enough on its own. Sizing text off container units
means choosing a coefficient for some assumed number of characters, and the
value here is not a fixed width: the coefficient that fills a tile nicely
for "-45.9" pushes "-100.5" nine percent out through the side. Measured it
rather than guessed — the tile now sets a trial size, reads the width the
actual text wants, and scales to fit. That runs only when the tile size or
the length of the reading changes, not on every frame.

It now fills about 98% of the width wherever width is the limit and 92% of
the height wherever height is, at every tile size from 180x70 up to
1400x700, with no overflow on either axis. The CSS keeps a conservative
size for the first paint before the measurement has run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:56:27 -04:00
Waters ChurchandClaude Opus 5 d2d4207fe4 SPL Meter 0.2.0: one measurement, many views
Every control asked for here is a view choice, so none of them may reach
the capture child — two dashboards watching the same room must be able to
disagree about how to draw it without disturbing the measurement or each
other. That set the shape of the change: the child now sends everything
anyone might ask for, once, and each tile folds it down.

Averaging (was "Reading") is Fast, Slow, Inf and None, and the child
computes and sends all four every frame. Fast and Slow are the IEC time
constants, Inf is Leq since reset, None is the block with no time constant
at all. The admin-wide time constant is gone — it was never a property of
the room.

Banding is 1/3 through 1/48 octave. The wire always carries 1/48, and
because every banding offered divides 48 a coarser band is an exact sum of
finer ones in the power domain. Nothing is resampled and no tile's choice
is visible to another. The transform grew to 16384 points to have bins fine
enough to mean it; below roughly 200 Hz a 1/48 band is still narrower than
one bin, which the Banding help says out loud rather than pretending.

Each tile kind now carries its own settings, which is the fix for the
complaint that started this: the RTA was offering an alarm threshold and a
metric picker that did nothing to it. An entry's instanceSchema replaces the
module's, so a gear popover only holds controls that change that tile.

Also: frequency labels in three tiers that thin out as a tile narrows
instead of smearing, gridlines off by default on both graphs, RTA smoothing
as a damping factor on the drawn value only, a title-bar toggle to swap the
spectrogram's axes, and the threshold blurb removed.

Settings saved by 0.1.0 are mapped forward — a layout on a wall should not
start reading dashes because a field was renamed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:56:27 -04:00
Waters ChurchandClaude Opus 5 e90ae51495 Never build the capture tool on the event loop
The missing-binary fallback was a spawnSync, copied from the LTC listener
where the source is a third the size and builds in a few seconds. On the
production Mac this file took nearly two minutes, and spawnSync held the
Node event loop for every second of it: one module's optional helper froze
the entire dashboard, every other module with it, and then did it again
thirty seconds later on retry.

The build is now a plain spawn that reports "building" and calls start()
itself when it lands, so nothing waits on it. A failed build says so once
instead of spinning, and a build that never finishes is given up on after
five minutes.

The real lesson is the one in the comment: the binary should already be
there. It is built beside the app, or copied from any Mac of the same
architecture. Building on the machine that might be running a service is
the fallback, not the plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:56:27 -04:00
Waters ChurchandClaude Opus 5 188d1c878d SPL Meter module: measure the room, not just watch it
A sound level meter that lives in ProdDash rather than beside it. Takes an
audio input the way the Timers module's LTC reader does, and offers four
tiles: the level as a big number, per-channel bars with peak hold, a
third-octave RTA, and a scrolling spectrogram.

The measurement happens in spl-capture.swift, not in Node and not in the
browser. LTC gets away with piping raw samples upstream because the decode
is cheap and single-channel; sound level is filtering and FFTs over every
channel at the device's rate, so the child does the work and only the
answers cross the pipe. That costs nothing in dependencies — Accelerate
ships with macOS, and the whole thing builds in two seconds with a plain
swiftc, exactly like ltc-capture.

The weighting filters are the part worth checking rather than trusting, so
`spl-capture --selftest` prints them against the IEC 61672 table. A- and
C-weighting land within 0.15 dB from 31.5 Hz to 4 kHz and exactly 0 dB at
1 kHz. The top octave is where the bilinear transform runs out of room: at
48 kHz the 16 kHz point sits about 3 dB low even with the pole pair
prewarped, and at 96 kHz that falls to 0.6 dB. It is a rounding error on a
broadband number and a real limit all the same, which is why the selftest
prints both rates instead of a pass mark.

Cross-checked against REW on the same microphone: after removing the fixed
calibration offset the two agree to a standard deviation of 0.5 dB on live
room noise.

Uncalibrated is a state the module says out loud — every tile reads dBFS
and labels it, so nobody mistakes a relative number for sound pressure. The
/measure route averages the calibrator tone in the power domain and hands
back the offset to paste into settings; a module cannot write its own admin
config, so that last step stays manual on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:56:27 -04:00
michael 5025e12b71 Merge pull request 'Rules for a clean interface, and the modules brought to them' (#9) from claude/module-rules into main 2026-09-17 15:52:59 -04:00
michael 3009b39453 Merge pull request 'Twelve themes, and the Theme section folds away' (#8) from claude/themes into main 2026-09-17 15:52:24 -04:00
michael 41f64f3a13 Merge pull request 'Admin PIN: the fields say enough' (#7) from claude/admin-pin-trim into main 2026-09-17 15:51:34 -04:00
Waters ChurchandClaude Opus 5 12f0b20e5f Rules for a clean interface, and the modules brought to them
Seven rules join the four in the module guide, as rules 5 to 11: no
explanatory text in a tile's gear and no label over three words; server-side
settings in configSchema only; a tile setting changes that tile alone;
hide-toggles ungrouped when there are four or fewer and in a folded "Show"
group when there are five or more; title-bar controls are icons with
tooltips, never names; anything needing words belongs in the gear; and one
control per action, either place but not both.

Two passages said the opposite and now say the same thing — "keep labels
short and put the explanation there" is scoped to configSchema, and "icon
buttons, short labels" becomes icons with no labels at all. CLAUDE.md
carries the summary.

Then the modules. Nothing here changes a setting key or a default, so saved
layouts, named layouts and every tile already on a wall keep working; group
names, labels, help and fold state are presentation and are not stored.

- pco-plan: sixteen labels cut to three words or fewer ("Tint items by type
  (song / media / other)" is now "Tint by type"), "Show / hide" renamed
  "Show" and folded. Its one remaining help line stays by request: the top
  bar's template needs its separator syntax spelled out, which the variable
  chips beside it cannot convey, so rule 5 carves out template fields.
- propresenter-timers: the "Timers ▾" menu was the only named control left
  in any title bar. It is a stopwatch icon now, with the count riding
  alongside it while some timers are hidden — setLabel() writes over the
  button's contents, so the count needed ProdCom's badge treatment rather
  than a label. "Show" folds; the Colours help goes.
- propresenter-now-next: "Show" folds, in the manifest and in the Now
  entry's own copy.
- prodcom-transcript: "Show channel icons" loses the redundant verb.

slack and clock were already compliant and are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:22:06 -04:00
Waters ChurchandClaude Opus 5 f72170b4e1 The folding section reads as a heading first
Theme's title was coming out at the normal weight — <summary> does not
inherit an <h1>'s boldness, so sitting it under .admin-h1 got the size,
colour and spacing but not the type. It now matches the other section
headings exactly.

The caret moved to the right of the words. A marker in front pushes the
heading out of line with every other one on the page; behind it, the section
still reads as "Theme" first and the caret only says there is more.

Written as the pattern rather than as Theme's own styling: any section that
folds is a <details class="admin-fold"> headed by a
<summary class="admin-h1 admin-fold-title">, and the whole row is the target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:22:11 -04:00
Waters ChurchandClaude Opus 5 ea1eac1c28 Twelve themes, and the Theme section folds away
Seven more palettes: Midnight (indigo, violet), Moss (forest floor, lime),
Plum (aubergine, purple), Sepia (warm brown with a cool teal — Ember's
arrangement reversed), Blackout (true black for a dark room, where any glow
off the screen carries), Paper (the warm light one, next to Daylight's cool
white) and Iron, which has no colour of its own: its accent is near-white, so
the only hues on screen are a module's own and the red and amber that mean
something.

Each holds the line the first five set. Text on panel lands between 14.8:1
and 19.8:1, muted between 5.8:1 and 7.4:1, and every accent clears its own
theme's danger red and warning amber by more than Ember already does — the
narrowest of the new ones is Moss at 54 degrees of hue, against Ember's 18.

The Theme section now starts folded, wearing the same disclosure triangle as
a module's config groups. A building picks its palette once and then wants
the rest of the page, which was getting further down the screen with every
swatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:16:47 -04:00
Waters ChurchandClaude Opus 5 ea8be2ac76 Admin PIN: the fields say enough
The paragraph under the PIN fields explained what the PIN covers and how to
clear it by hand — and only someone already past the PIN could read it, so
the recovery note was never where it was needed. Gone, with the dead style
and the element lookup that hung off it.

The live line under the heading stays: it is the only thing that says
whether admin is locked, and the only warning that a passcode in the
server's environment outranks whatever is set here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:05:53 -04:00
michael 82e6386c73 Merge pull request 'Shell 1.6.0: bars that tuck away, and every edge resizes' (#6) from claude/tile-chrome into main 2026-09-17 12:44:07 -04:00
michael f5eb611872 Merge pull request 'README: the Slack module joins the list of included modules' (#5) from claude/readme-slack-module into main 2026-09-17 12:43:31 -04:00
michael d52cfcc48e Merge pull request 'Slack 1.2.1: a clearer Send tile, and a send target you can see' (#4) from claude/slack-send-target into main 2026-09-17 12:42:48 -04:00
michael 7d46cfbd8f Merge pull request 'ProPresenter 1.1.0: a Now tile, and switches for everything on it' (#3) from claude/propresenter-now into main 2026-09-17 12:41:53 -04:00
michael 7a5b57dc56 Merge pull request 'Admin sits behind a 6-digit PIN' (#1) from claude/admin-pin into main 2026-09-17 12:40:31 -04:00
Waters ChurchandClaude Opus 5 7f50fa0ba9 A pin on the title bar, and no more notch
Pointing at a tile's top edge already brings its bar back, which left the
notch doing half a job: it could still hide a bar, but hiding one tile at a
time stopped being the interesting question once the whole dashboard could
fold its bars at once. It is gone.

In its place, a pin beside each tile's label. Pinned, that tile keeps its
bar while auto-hide is on — for the one or two tiles whose controls are
wanted at a glance. It sits next to the label rather than out with the gear
and ✕ because it is about the bar, not about the tile's contents, and it
stays faint until the tile is pointed at or the pin is on, so a wall of
unpinned tiles is not a wall of pins.

Fullscreen keeps its own rule: every bar folds, pinned or not, because that
is a mode for watching. What it loses with the notch it gets back better —
the same hover at a tile's top edge peeks a bar there too, and now as an
overlay, so peeking no longer pushes the tile's content down and disturbs
the auto-fit. The peek timer the notch needed goes with it.

Per-tile `headHidden` gives way to `headPinned` in the layout, on disk and
on the server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:04:59 -04:00
Waters ChurchandClaude Opus 5 68f0ba806d Tiles resize from anywhere, so nothing marks the spots
Every edge and both bottom corners resize now, so the little marks that used
to appear on hover were pointing at the whole rim. They are gone — corners,
edges and the seam pill alike — and the cursor over an edge is what says it
can be dragged, the way a window's own edges do it. The seam still shows
itself while it is actually being dragged, which says what is moving rather
than where you may push.

With nothing drawn, the grips could be made to cover the rim properly: the
top strip now runs corner to corner instead of stopping 24px short, the
sides run from it down to the bottom corners, and the bottom strip fills
between those. No dead spots left along an edge.

The side grips also stopped standing down at a seam. They only did so to
avoid stacking three marks in one place, and it cost real ground: a tile
taller than its neighbour lost its whole side, not just the part the seam
covers. The seam sits above them anyway, so it still wins where it exists
and the rest of the edge now works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:52:45 -04:00
Waters ChurchandClaude Opus 5 8e579ecb34 Shell 1.6.0: bars that tuck away, and every edge resizes
Three things about arranging a dashboard.

**Auto-hide bars.** A new button left of ⛶ folds every tile's title bar away
at once, for a windowed screen that should read like a wall display.
Fullscreen already did this for watching; this is the windowed equivalent,
asked for by hand. It starts off and saves with the layout, so a booth's
named layout loads with the bars already tucked away.

**A hidden bar is not a gone bar.** Folded away — by that switch or by a
tile's own notch — the bar leaves the flow and parks just above its tile, so
the module keeps the whole body and nothing reflows as the bar comes and
goes. Point at the tile's top edge and it slides back in over the content,
where its buttons click and its width drags the tile exactly as before. A
dashboard can now be arranged with every bar out of the way, which is the
point of hiding them.

**Every edge resizes.** The top, left and right edges join the bottom one:
each moves only itself, so the tile grows into the space beside it while the
opposite edge stays put, stopping at a neighbour or at the module's own
minimum. Where two tiles meet, that boundary already belongs to the seam
pill between them — it moves both edges and leaves no gap — so the two
tiles' side marks step aside there rather than offer a second, different
control on the same pixels.

One fix underneath: saving a named layout was dropping each tile's variant,
title, minimum size and hidden-bar state, so a saved "Now" tile came back as
the full Now/Next card and a solo timer came back with a full tile's
settings. The layout file now carries them — and the new switch with them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:39:34 -04:00
Waters ChurchandClaude Opus 5 c379025d30 README: the Slack module joins the list
Every shipped module had an entry under "Included modules" except Slack,
which wasn't mentioned anywhere in the README — so the one module that can
send as the campus was also the one nobody could read about.

Its entry now sits with the others, in their shape: what it does, the tiles
it offers in the + picker (one transcript per configured channel plus the
two single send surfaces), its four admin groups and its tile settings —
all taken from modules/slack/module.json and client.js.

The testing paragraph listed the ProdCom, ProPresenter and PCO mocks but
not the Slack one, though CLAUDE.md has documented it all along; it is now
there with the SLACK_API_BASE line it needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 09:10:03 -04:00
Waters ChurchandClaude Opus 5 f0be37796b Slack 1.2.1: a clearer Send tile, and a send target you can see
The line above the message box said "Sending as Apollo Beach → #ops" with a
Lock button beside it. The box's own placeholder already reads "Message
#ops…" and the title bar already has a Lock, so both went. Quick replies
keeps its line: it has no placeholder, so that is the only place its target
is named.

The bigger thing behind it: until a transcript was clicked, a send went to
the first channel in the admin list whether or not that channel was on the
dashboard — so a board showing only #ops would quietly aim at #general. The
target now falls to the first configured channel whose transcript is
actually up, which with a single transcript is that one. Only when the page
shows no transcript at all does it fall back to the first configured
channel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:58:57 -04:00
Waters ChurchandClaude Opus 5 52d700df3d ProPresenter 1.1.0: a Now tile, and switches for everything on it
The + picker now offers the module two ways. "Now / Next" is the card as it
was. "Now" is the live item on its own: the gradient comes off the card and
fills the tile edge to edge, the border, radius and box go, and the playlist
name and live dot float over the colour instead of sitting above it. A wall
of them reads as panels of colour rather than cards on a background.

Every part of the tile is now a switch in a "Show" group in the gear —
playlist, live status, the NOW and NEXT labels, slide count, progress bar,
the next list and its item tags. One or two words each, no descriptions. The
Now entry carries its own six, since the three Next ones have nothing to act
on there.

Two things the tile still insists on. Trouble overrides the switches: the
top row and the live chip come back the moment the feed drops, whatever is
switched off, because a tile that has stopped updating must never look like
one with nothing live. And at the Now tile's shortest sizes the floating top
row would sit on the title, so below the height it needs it goes — the
header dot and the title itself still say what is wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:39:55 -04:00
michael 6cca6d4d1a Merge pull request 'The church's own Gitea is where ProdDash lives' (#2) from claude/gitea-is-home into main 2026-09-16 19:00:51 -04:00
Waters ChurchandClaude Opus 5 1be5cbef31 The church's own Gitea is where ProdDash lives
The shipped defaults pointed at GitHub, so a fresh install with no
configuration would have looked there — and that repository is going away.
They now name michael/ProdDash on chronicles-ab.waterschurch.org:3000, which
a fresh install was verified to reach with nothing configured at all.

The hostname rather than the IP, because the UniFi DNS record is
authoritative and this survives the NAS changing address. Both production
machines already carry an explicit setting of their own, so neither is
affected either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:00:22 -04:00
Waters ChurchandClaude Opus 5 7690ae1fad Merge main into the admin PIN branch
Both sides added to the same two places — the environment-variable list in
server.js and the settings table in the README — because the forge work and
the PIN work each documented a new setting. Everything from both is kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:57:27 -04:00
Michael McGary 1c8cd63cd0 Merge pull request #20 from michaelmcgary2008/claude/shell-1-5-0
Shell 1.5.0
2026-09-16 18:25:02 -04:00
Waters ChurchandClaude Opus 5 af6322d079 Shell 1.5.0: updates can come from your own git server
The forge setting is a new capability, so the version moves — which is
also how a machine already running 1.4.0 is offered the update at all
(an archive install compares versions, having no commit of its own).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:24:57 -04:00
Michael McGary 168f717398 Merge pull request #19 from michaelmcgary2008/claude/self-hosted-forge
Updates can come from your own git server
2026-09-16 18:23:38 -04:00
Waters ChurchandClaude Opus 5 67bcd3640a Updates can come from your own git server
ProdDash fetched one tar.gz of the branch from GitHub's codeload host, with
that URL shape hardcoded. A `forge` setting now names where the code comes
from: absent or "github" behaves exactly as before, and a Gitea or Forgejo
base URL such as http://10.3.8.90:3000 fetches from a server of your own.

Gitea spells an archive URL differently — /archive/<branch>.tar.gz rather
than /tar.gz/<branch> — and that is the only real difference. Its tarball
carries the same pax header with the same commit hash, so the archive
parser, update detection and module installs are untouched. The "what
changed" link follows the forge too, using each one's own compare and
commits paths.

Verified against both: the same commit and version come back from GitHub
and from a self-hosted Gitea, and a module installs from each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:23:20 -04:00
Waters ChurchandClaude Opus 5 6dabd9d8a1 Admin sits behind a 6-digit PIN
The passcode machinery existed but could only be set by hand in a config
file, so in practice every install left admin open to anyone on the
network. It is now a PIN you set from the admin page itself.

- Six digits, stored as a scrypt hash in this machine's proddash.json —
  never in the clear, since that file gets copied between machines.
- The unlock card takes the PIN and submits itself on the sixth digit.
  Five wrong tries from one address locks it out for 30 seconds, and the
  right PIN is refused during the lockout.
- Changing or turning off the PIN asks for the current one, so a browser
  left open on the admin page cannot be used to take the machine over.
  Whoever sets it keeps their session rather than being bounced to the
  card they just built.
- This is the shell's own PIN and guards /admin and the admin API only.
  Dashboards stay open, and it is unrelated to a module's PIN — Slack's
  4-8 digit send PIN still guards sending, separately and independently.
- Ways back in if it is forgotten: PRODDASH_PASSCODE in the environment
  (which overrides the PIN), or delete adminPin from proddash.json and
  restart. Both are in the README and on the page itself.
- The older free-text adminPasscode still works, so an existing install
  keeps running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 10:46:41 -04:00
Michael McGary 095fe66118 Merge pull request #17 from michaelmcgary2008/claude/no-campus-defaults
No campus's address ships in the repo
2026-09-16 10:38:32 -04:00
Waters ChurchandClaude Opus 5 a3e1282354 No campus's address ships in the repo
prodcom-transcript's endpoint defaulted to 10.3.11.152:24480 — Apollo
Beach's ProdCom. A fresh install anywhere therefore came up reading AB's
comms and reported itself healthy doing it: that is exactly what the new
North Attleboro install did. The default is now blank, so a new install
says "No ProdCom server configured" (in red, as the ProPresenter modules
already do for a missing host) until someone points it at the ProdCom for
that location. Setting a host still connects as before.

The same address is out of SPEC.md's worked example, CLAUDE.md's note
about the reference app, and the prodcom-listener snapshot's config,
README and fallback — that fallback now exits with a line telling the
operator what to set, rather than throwing on an empty URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 10:37:56 -04:00
Michael McGary 6417c3aa54 Merge pull request #16 from michaelmcgary2008/claude/slack-module
Slack module: channel transcripts, PIN-gated sending as the location
2026-09-15 15:25:24 -04:00
Michael McGary c1d9fa0527 Merge pull request #14 from michaelmcgary2008/claude/proddash-app-tweaks
Shell: quieter menu handle, calmer fullscreen, live tile settings
2026-09-15 15:24:27 -04:00
Waters ChurchandClaude Opus 5 720e5bb590 Shell: every tile can be tinted from its gear
A colour picker at the foot of every gear window washes that tile's
chrome — panel, border and title bar — in a colour of the operator's
choosing, so one screen's tiles can be told apart at a glance. "Clear"
takes it off again. The value saves with the layout under the reserved
key __tint; it is the shell's, not a module's, so picking a colour paints
the tile without remounting the module, and every tile now has a gear
even when its module offers no settings of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:39:35 -04:00
Waters ChurchandClaude Fable 5.1 f29e43373b Timers: cards keep their text when grouped by source
The sizing budget counted rows as if every card sat in one grid unless
headings were showing, so a grouped tile without headings got digits
sized for fewer rows than it had and the lines below were cut off. Rows
now follow the grouping itself; each group's share of the height is the
exact height its rows, gaps and heading take, so every row is the same
height; and after the layout is applied each card's real height is
measured and digits that would still spill are shrunk to fit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:25:37 -04:00
Waters ChurchandClaude Fable 5.1 dde9a00ee1 Timers: "Group by source" on the All timers tile, off by default
Off, every timer sits in one grid filled row by row — a wrapping flex row
rather than a CSS grid, so the last row's cards widen to use the whole
width and nothing is left empty; a source in trouble is named in one line
above the cards, and its own cards dim. On, the sections per source come
back, and "Source headings" appears (showWhen) to hide their titles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:56:22 -04:00
Waters ChurchandClaude Fable 5.1 96eff1a5c5 ProdCom 1.3.0: one connection to ProdCom, shared by every dashboard
The module used to proxy each browser's transcript stream to ProdCom one
to one, so N screens meant N upstream connections and N history reads.
It now follows ProdCom once: it keeps the channel list, the groups and
the last 300 entries, and gives every browser a snapshot on connect
(`state`) plus each changed entry as it happens (`entry`), with `clear`
and `status` frames as the link changes. Reconnects back off from 2 s to
15 s, re-read the lists and history, and re-send the snapshot. The tile
no longer loads anything itself; the admin readout says how many channels
and entries the server holds. The /prodcom/* proxy is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:43:08 -04:00
Waters ChurchandClaude Fable 5.1 ec178e9ccd Guide: streams reach the page over one shared connection
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 11:04:18 -04:00
Waters ChurchandClaude Fable 5.1 680a34ac7a Shell: one stream connection per page
Browsers allow about six HTTP/1.1 connections to a host and an open
EventSource holds one. Sharing a stream per URL was not enough: a
dashboard with six modules still ran out — Safari first — and every later
request (a remounted tile's /state, an avatar) waited in the browser's
queue for ever; a Timers clock tile went blank after any settings change.

The page now holds ONE EventSource, GET /api/stream?s=<keys>, naming the
streams it wants (`slack:/stream`, `shell:` for the shell's own events).
The server relays each module stream into it over loopback — module
routes are untouched and still serve curl — prefixing every event name
with its key, and brackets each stream's life with `<key>|__open` and
`<key>|__down` (re-opened after a pause). sseSubscribe keeps its shape:
subscribers see their events by their own names, a stream joining the
connection is remade with the new list a moment later, and every stream
announces `open` again after it so backfills run as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 11:02:36 -04:00
Waters ChurchandClaude Fable 5.1 818def822d Shell: tooltips at once, one or two words; text-size buttons are drawn − and +
Every header and top-bar button's title becomes a tip the shell shows the
moment the pointer arrives (the browser's own waits a second and looks
different everywhere); it moves from `title` to data-tip so the native one
never appears, and a module that sets `title` again is picked up on the
next hover. The shell's own labels are "Settings" and "Remove"; the top
bar's are "Fullscreen", "Add tile", "Layouts", "Admin"; Slack, PCO Plan,
ProdCom and Timers shorten theirs to match. A− / A+ are a drawn − and +
in Slack, PCO Plan and ProdCom, sized like every other header icon.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:49:58 -04:00
Waters ChurchandClaude Fable 5.1 1930fb142d Shell: the Admin button is the cog, icon-only with a tooltip; the + is drawn
Every top-bar button is now an icon with a hover title and no text. The
Admin link uses the same cogwheel as the tile gears; the add button's +
glyph (font-dependent, uneven across browsers) is an SVG plus.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:35:29 -04:00
Waters ChurchandClaude Fable 5.1 eb71bd7b52 Slack: no help line under the Channel notices switch
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:25:27 -04:00
Waters ChurchandClaude Fable 5.1 e37060afff Shell: the tile close button is a drawn cross
The ✕ glyph came from whatever font the browser chose — smaller in
Safari than in Chromium. An inline SVG at the icon size is the same
everywhere and still takes the button's colour on hover.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:24:44 -04:00
Waters ChurchandClaude Fable 5.1 11de9cd150 Slack: an Avatars switch in the transcript's gear
Off, the picture square goes and each row starts at the left edge — the
same behaviour as ProdCom's channel-icon switch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:23:02 -04:00
Waters ChurchandClaude Fable 5.1 857a53fec8 Shell: the tile gear is a cogwheel
The settings button's icon is the cogwheel the user supplied, drawn in
the button's colour so it follows hover and the open state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:19:31 -04:00
Waters ChurchandClaude Fable 5.1 fe04627493 PCO Plan: "Starts in" counts days past 24 hours
A service more than a day away reads "2 Days, 05:12:44" (or "1 Day, …")
in the top bar's countdown instead of a growing hour count; under a day
it reads as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 09:58:47 -04:00
Waters ChurchandClaude Fable 5.1 efbcf960dd Timers: the gear chooses what a card shows, not which timers; a 24-hour switch; a date that fits
Which timers appear is the header's Timers ▾ menu's job, so the gear's
source switches (ProPresenter / LTC / module timers, the Clock on/off)
are gone. In their place the Show group offers the card's elements —
Names, Status lines, Detail lines, Seconds, Leading zeros, Source
headings; without seconds a countdown reads in whole minutes, rounded
up. The clock's format select is a 24-hour switch (an older layout's
"12h" choice is honoured); the clock card has no CLOCK label; Short day
and Short month appear once Date is on, and the date fits its card — one
line when it can, else a break after the day and a size that fits the
longer half. Solo tiles get the same element switches.

Shell: the settings window's grab bar is the accent colour and the gear
that opened it wears the same colour while it is open; showWhen works in
a tile's gear as it does on the admin page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 09:53:41 -04:00
Waters ChurchandClaude Fable 5.1 1db6c2c89f Timers: "to 9:30a" under a countdown-to-time, smaller status lines, titles that fit
A ProPresenter count_down_to_time timer's detail line reads "to 9:30a"
(or "to 21:30" on a 24-hour clock) instead of a sentence. The status and
detail lines under the digits are drawn at 0.8 of the title's size, and
fit() budgets them at that share. A title that would be cut off shrinks
to fit its card (measured off-screen, down to 8 px) instead of ending in
an ellipsis; a changed label re-fits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 09:05:56 -04:00
Waters ChurchandClaude Fable 5.1 0acaf47ac7 Slack: transcript rows get their own class; Names switch works with the new rows; notices switch renamed
The row shared its class with the Send tile's status line, so every
transcript row was drawn at 0.8em, muted and nowrap; rows are .sl-row now
and read at the tile's text size. The Names switch targeted the old
markup (name line directly in the row) and hid nothing once the rows
gained a body. "System lines" reads "Channel notices", with a help line
naming what those are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 17:10:36 -04:00
Waters ChurchandClaude Fable 5.1 cc148926be Slack: log the token's scopes, and which optional ones are missing
Slack names a token's scopes in a header on every answer; the module
now logs them once the token is checked, and says plainly when
reactions:write or emoji:read is absent and how to add it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 16:58:35 -04:00
Waters ChurchandClaude Fable 5.1 ca696a3931 Slack: a tighter PIN pad, so a three-row tile keeps its keypad
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 16:50:47 -04:00
Waters ChurchandClaude Fable 5.1 20d4e0f524 Shell: settings window sized to its content, with a slim grab bar
No title and no ✕ — the window sits beside its tile and closes on a click
anywhere else or Escape — and it is as wide as its fields want, so a
couple of switches make a small window. Help lines wrap to the fields'
width instead of widening it.

Guide: one control per action — whatever has a header button is not
offered again in the gear. ProdCom 1.2.2 drops its timestamps and
newest-first gear switches (both are header buttons).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 16:31:02 -04:00
Waters ChurchandClaude Fable 5.1 5dd1b734aa Slack 1.2.0: emoji everywhere, reactions from the transcript, quick emojis, one PIN per page
Emoji: every standard :code: in a message becomes its character (the full
Slack table ships as emoji.json, from iamcal/emoji-data, MIT); the
workspace's custom emoji come from emoji.list and draw as images proxied
through the module; skin tones work on both.

Reactions: messages show their reactions as pills (the location's own in
the accent). A right-click on a message opens a menu — the admin's quick
emojis, then a search over every emoji — and a click on a pill adds or
takes back the location's reaction. It goes through the same gate as a
send (reactions:write on the app).

Rows follow the ProdCom transcript's layout: a square profile image or
bot icon (initials on a hue tint when there is none), the name line with
its time, the text, the reactions. The accent bar on the location's own
messages is gone.

Send message: the admin's quick emojis sit left of Send, one press sends
one. The PIN pad submits by itself once the PIN's digits are in (the
server sends the length, never the PIN) — no ✓ key; in a tile too short
for the keypad the dots and a hint remain and the keyboard still works.
One PIN unlocks every Slack tile on the page (Send message, Quick replies,
reactions) until auto-lock; a remount keeps it, a reload asks again.

The transcript's gear lists its three switches without a group. The mock
serves reactions.add/remove, emoji.list and profile images.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 16:31:02 -04:00
Waters ChurchandClaude Fable 5.1 70134d6dfc Shell: a picker entry can name the older variant ids it stands for
Layouts saved before Timers 1.3.0 hold 'timer:<uuid>' and 'ltc' tiles.
An entry's aliases let those tiles get the entry's slim gear settings
instead of falling back to the module's full schema.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:40:36 -04:00
Waters ChurchandClaude Fable 5.1 3ceac9599b Merge Timers: each picker entry carries the gear settings that fit its tile
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:39:38 -04:00
Waters ChurchandClaude Fable 5.1 a18d3d35ea Shell: wide fields take the whole row inside a column group
A multiselect's switch grid or a template's chips squashed into one
cell when their group also laid its fields out in columns; they now
span the group's full width.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:37:36 -04:00
Waters ChurchandClaude Fable 5.1 f7a2856267 Merge PCO Plan: note categories as switches, top-bar variable chips, no text-size field
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:37:13 -04:00
Waters ChurchandClaude Fable 5.1 b817e230d4 Timers: each picker entry carries the gear settings that fit its tile
An "All timers" tile keeps the full set — sources, headings, status lines,
the clock's format (now behind the Clock group's title-row switch), the
colours. A solo tile's entry carries only what its one card can use: a
ProPresenter or module timer gets Status lines and the four colours; the
LTC timecode Status lines and the three tones it takes (running, no
signal, stopped); the time-of-day clock its format, seconds and date
alone; a clock another module offers its format plus the idle tone.

Every field is still defined once, in module.json; server.js composes the
subsets from it. The client resolves an absent setting from its own
DEFAULTS rather than from whatever schema the tile has, so a slimmer
schema changes nothing about what a tile shows. Entry descriptions are
one-sentence picker tooltips now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:36:41 -04:00
Waters ChurchandClaude Fable 5.1 0c4828eb26 pco-plan: note categories as switches, top-bar variables as chips, no text-size field
Item notes get their own gear group, after Colors, with the existing
showNotes switch as the group's on/off in its title row. Its categories
come from Planning Center (GET /note-categories → the service type's
item_note_categories, by name, cached 10 min, an empty list rather than
an error when unconfigured or unreachable) and render as switches, three
across, all on until someone flips one; the tile reads an unset value as
every category, an array as exactly those, and a layout's old
comma-separated string as that list (blank = all).

The top bar template is a `template` field: one chip per placeholder
({series} {part} {serviceType} {date} {shortDate} {time} {length}
{items}) to drag or click into the text, with the help down to the
joining rules. Rendering is unchanged.

Text size leaves the gear window: the title bar's A− / A+ already size
the text and save it with the tile; the client falls back to 14 px when
nothing is saved.

tools/pco-mock.js serves item_note_categories (the categories its notes
use plus "Stage", which none does yet) so the route can be exercised.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:35:47 -04:00
Waters ChurchandClaude Fable 5.1 444698c907 Guide: text size is a header control, never a gear field
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:28:58 -04:00
Waters ChurchandClaude Fable 5.1 03d13758a9 Slack: text size is set from the tile header, not the gear popover
The tile header's A− / A+ buttons already size the transcript and
persist through saveInstanceSettings; the gear popover no longer
duplicates that as a number field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:27:49 -04:00
Waters ChurchandClaude Fable 5.1 7a44b25d4b Shell: tile settings in a window beside the tile; schemas per tile kind
The gear popover lived inside its tile, so a tile could be too small for its
own settings, and the settings sat on top of the thing they change. It is now
a small window: placed beside the tile (right when there is room, else left,
else over it), clamped to the viewport, moved by a grab bar, closed by
clicking away, Escape, the ✕ or the gear. Settings still apply as they
change — that is the point of putting the window beside the tile.

With it, four pieces of contract the module work needs:

- A picker entry may carry its own `instanceSchema` / `instanceGroups`:
  tiles added from it get settings that fit that tile, nothing else. A tile
  showing one ProPresenter timer has no use for a clock's format switches.
- `instanceGroups[name].toggle` puts a group's on/off switch in its title row
  and dims the body while it is off — the admin page's convention, in the
  popover.
- `select` and `multiselect` in tile settings may take `optionsRoute`;
  `multiselect` also `columns` and `allByDefault` (nothing saved until
  someone flips a switch, so options that turn up later are on too).
- `template`: a text with placeholders built by dragging or clicking the
  variables a module offers into it.

And the + picker's descriptions are tooltips now — the list stays a list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:20:38 -04:00
Waters ChurchandClaude Fable 5.1 b9e895d324 Slack module: several channels, send tiles follow the transcript clicked last
One Slack tile per channel instead of one channel per machine. Admin picks
any number of the account's channels (`channels`, a multiselect fed by
/channels; a `channel` string saved by 1.0.0 is read as a one-item list, the
module never rewrites admin config). tiles() offers one transcript entry per
channel — id "transcript:<id>", named "#<channel>" so the shell titles the
tile with it, settings { view, channel } — plus Send message and Quick
replies, each `single: true`. A transcript tile from 1.0.0 (no channel in its
settings) shows the first configured channel.

Server: one poller per channel, each with its own window, thread cache,
membership and error, sharing the token, the name caches and the SSE fan-out.
Slack allows an internal app ~50+ conversations.history calls a minute per
token, so the module keeps itself under 40:

  interval per channel = max(pollSeconds, ceil(channels × 60 / 40)) s
  1 → 2 s, 2 → 3 s, 3 → 5 s, 5 → 8 s

with pollers phase-locked across that interval so they never fire in a
burst (first reads staggered 250 ms apart after boot), one auth.test shared
by all of them, and a 429 backing every poller off together. /state returns
`channels` keyed by id (name, isMember, messages, pollAge, error, status)
plus `channelOrder`; /stream stays one `state` snapshot per change. The
health line lists the channels and names any with a problem:
"Apollo Beach · #production, #ops · every 3 s (rate budget) · sending on".
/send and /quick take `channel`, which must be one of the configured ids
(400 otherwise) — a PIN-unlocked browser chooses among the admin's channels,
never an arbitrary one; a quick reply's own "| #channel" wins over it.

Client: a click anywhere in a transcript's body makes its channel the send
target; the chosen transcript carries a 3px accent stripe along the bottom
of the tile body (the chrome is the shell's), the others hint at it on hover.
The selection is one fact about the whole page, so it lives in a deliberate
module-level store in client.js (an EventTarget plus the id, mirrored in
localStorage under proddash:slack:sendTarget so a reload keeps it) — the
comment there says why this is not the per-instance state the guide forbids.
Send message and Quick replies show "Sending as Apollo Beach → #ops" and
follow the selection live; when the selected channel is no longer configured
or its transcript tile is gone, sends fall back to the first configured
channel, and the headers say so. PIN pad, lockout, unlock tokens, Enter to
send, auto-lock, the transcript rendering and the per-tile settings are as
they were.

The mock gains a quiet #ops trickle so two transcripts visibly move at their
own pace; a post to either channel already lands in that channel's history.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 13:28:19 -04:00
Waters ChurchandClaude Fable 5.1 7d34604f2f Shell: single-instance tile entries, and a multiselect admin field
Two small pieces of contract for modules that offer several tiles from live
data:

- A `tiles()` entry may say `single: true`: at most one such tile per
  dashboard. The + picker greys the entry out once it is placed ("Already on
  this dashboard") and `addTile` refuses a second — for a control surface
  that would only confuse in duplicate, a send box say.
- Admin config gains `multiselect`: several picks from one list, the same
  `options` / `optionsRoute` as `select`, one switch per option, stored as an
  array of values. A saved value the list no longer offers stays checked and
  marked rather than being dropped in silence. The wrapper is a <label>, so
  it is told to label nothing — otherwise a click anywhere would flip the
  first switch.

Both documented in the module guide.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 12:56:10 -04:00
Waters ChurchandClaude Fable 5.1 babe8263eb Slack module: channel transcript + PIN-gated sending as the location
Each campus signs its booth machine into Slack as the account named for
the location, so a message from ProdDash reads as "Apollo Beach" rather
than as whoever is at the desk. modules/slack (1.0.0) is three tiles from
one module: the live transcript of one channel, a free-text Send tile and
a Quick-replies grid — the last two behind a PIN pad, auto-locking after
a configurable idle time.

Server: one user (xoxp-) or bot (xoxb-) token, identified with auth.test;
conversations.history polled every N seconds (thread replies via
conversations.replies, capped per poll), diffed and fanned out as one
`state` SSE snapshot; mrkdwn rendered to safe text on the server; sends
through chat.postMessage (username/icon for a bot with "Send as"); PIN
compared in constant time with a 5-wrong → 30 s lockout, 32-byte unlock
tokens with a sliding expiry, 1 message/s, every send logged; readable
messages for invalid_auth / token_revoked / missing_scope /
not_in_channel / ratelimited; health reflects token → channel → poll →
sending. SLACK_API_BASE points it at tools/slack-mock.js (new).

Shell: the admin config form gains a multi-line field type `text` (a
textarea; one item per line by convention) — used for the quick replies.
Documented in the module guide; the mock is listed in CLAUDE.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 11:49:40 -04:00
Waters ChurchandClaude Fable 5.1 f0fbf8a8f6 Timers: no "Other modules" catch-all in admin
The Timers admin had a fourth group, "Other modules", whose one switch gated
every timer provider that wasn't PCO Plan. A provider that earns a switch
earns a section of its own — so the catch-all goes, any other provider found
is consumed as soon as it appears, and its name (not "Other modules") is what
the health readout lists it under.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 10:51:08 -04:00
Waters ChurchandClaude Fable 5.1 361b8bdaa8 Themes: text on the accent is its own variable; white on Daylight
Primary buttons hardcoded the dark text that reads on a mint accent. On
Daylight the accent is a deep green, and dark text on it manages 3.6:1 —
squinting territory. The colour that sits on the accent is now
`--accent-text`: dark on the four themes where that reads best (7–10:1),
white on Daylight (4.4:1, the better of the two). Modules can use it too;
the guide lists it with the palette.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 14:14:11 -04:00
Waters ChurchandClaude Fable 5.1 bed2212850 Admin: a folded card's readout shares its row with Configure
Folded, the status readout took a full line and Configure sat alone beneath
it. The readout now moves into the bottom row beside the button, the button
staying bottom-right even when a long readout wraps; opening the card puts
the readout back above the form, where it was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 14:10:39 -04:00
Waters ChurchandClaude Fable 5.1 cd15efa7e6 Admin: headings without lectures; "Saved layouts"
The Theme, Modules, Available modules and layouts sections each opened with a
paragraph explaining themselves. The controls explain themselves; the
paragraphs are gone. The one line that carried information — where settings
are saved on this machine — stays. "Named layouts" becomes "Saved layouts",
which is what they are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 14:06:30 -04:00
Waters ChurchandClaude Fable 5.1 e7b0ea6ebf Admin: a module card is a status line until you open it
Every module's settings were always spread out, so the page read as a wall
of forms. A card now shows what you check at a glance — dot, name, version,
description, status readout, the enabled switch, Uninstall — with one button
at the bottom right: Configure opens the settings and becomes Apply, which
saves without closing them. Clicking the module name opens and closes the
settings too (keyboard as well: it is a focusable button). A card stays open
across a reload for the rest of the session, so re-checking something after
an Apply doesn't fold it away. A module with nothing to configure has no
button.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 14:03:10 -04:00
Waters ChurchandClaude Fable 5.1 b9d9e2894a Admin: Apply on the right; Service timing ⓘ in three sentences
The module forms' "Save & re-init" becomes "Apply", sitting at the right of
its row with the status text on the left — where a form's action belongs. The
PCO Plan Service timing help was a paragraph behind its ⓘ; it now says the
same thing in three sentences: who owns the clock and when it hands over, how
a service starts and ends, and where the document lists come from.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:55:36 -04:00
Waters Church 54ed388591 Merge branch 'claude/timers-module-overhaul' into claude/proddash-app-tweaks 2026-09-13 13:27:03 -04:00
Waters ChurchandClaude Fable 5.1 555bc482ce Timers: steadier menu, ellipsised labels, saner warning window
- The Timers ▾ menu's rows are rebuilt only when the row set or a switch
  changes, not on every snapshot — rows no longer vanish from under a
  click. Their on-state fill uses the shell's accent: the menu hangs off the
  title bar, outside the tile root, where the tile's colour properties
  don't reach.
- A long timer label ends in an ellipsis (the label is its own box inside
  the flex name row).
- The warning window is capped at half a timer's length, so a 20 s
  countdown isn't amber for its whole run (it warns under 10 s; a 25 min
  one under 2:30).
- Server: the change key that gates SSE frames ignores startedAt (derived
  from a whole-second value, it jittered ±500 ms per poll and doubled the
  frame rate); ProPresenter errors name the cause (ECONNREFUSED) rather
  than fetch's "fetch failed".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:24:46 -04:00
Waters Church a43a5d3fac Merge branch 'claude/pco-plan-service-timers' into claude/proddash-app-tweaks 2026-09-13 13:13:45 -04:00
Waters ChurchandClaude Fable 5.1 820df909bf pco-plan: timers say when a service began and when the live item is stale
A running service with no live item (not following ProPresenter, nothing
matched yet) now reports "Started 9:00 AM" instead of an empty status,
and the current-item timer is muted and flagged "ProPresenter
unreachable" while the feed or ProPresenter is down — it keeps counting
the last item we knew of, but the wall can tell it is stale.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:12:04 -04:00
Waters ChurchandClaude Fable 5.1 1e92454e76 Merge commit '184985b' into claude/timers-module-overhaul
Brings the admin page's implementation of the contracts this module builds
against: configGroups help as an info glyph, group toggles in the title row,
themes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:04:17 -04:00
Waters Church 790ca5ec97 Merge commit '184985b' into claude/shell-tile-fixes 2026-09-13 13:04:07 -04:00
Waters ChurchandClaude Fable 5.1 0fd052e49d Shell: tile settings are switches, and the popover ends where it should
Every boolean (and `switch`) in a tile's gear popover renders as the same
toggle the admin page uses for its module switches — 34×19 track, accent
when on, border track and muted knob when off — with the label on the
left and the switch on the right, so the two surfaces read the same. On
means shown or enabled, off hidden or disabled; the label names the thing.
A real checkbox still sits under the track: Tab reaches it, Space flips it
(with a focus ring), clicking the label text toggles it, and the existing
live-commit wiring reads it unchanged. `switch`-typed instance fields,
which previously fell through to a text input, take the same path.

The popover's bottom was 20 px of nothing: the last field kept its 10 px
margin under the popover's own 10 px padding once the Apply/Cancel row
went. The last child now drops its margin, top and bottom match (11 px
each, border included), and in column layouts the grid gap alone spaces
the rows.

Class names, for the admin page to mirror: `.field.check` (the row),
`.check-label`, `.switch`, `.switch .track`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:03:58 -04:00
Waters ChurchandClaude Fable 5.1 c46f98818e Shell: the + picker opens at once, and tiles share their streams
The Add-tile menu would stop appearing once enough tiles were on the
dashboard — a different count each time. The button's handler awaited
GET /api/modules before revealing the menu, and that request could never
complete: browsers allow about six HTTP/1.1 connections per host, an open
EventSource holds one for as long as it lives, and every stream-fed tile
(ProPresenter, Timers, PCO Plan, ProdCom) opened its own. With the shell's
own /api/events that is the whole allowance at five or six such tiles, and
every request after that — the picker's module list, a new tile's /state,
its stream — sat in the browser's queue for a connection that was never
handed back. The count varied because a stream in its 3-second retry loop
frees a connection for a moment, and a clock tile holds none.

Reproduced with six stream-fed tiles: the sixth stream and the picker's
/api/modules both pend forever, and the menu stays hidden after the click.

Three changes, all in the shell:

- Dropdowns open the moment they are clicked, from what the page already
  knows, and slot in what their rebuild fetches when it lands. A rebuild
  that fails leaves the previous contents rather than an invisible menu,
  and a generation token stops a late fetch from repainting a menu that has
  since been closed or reopened (the old await also let two quick clicks
  cancel each other out). The + picker repaints only when the module list
  actually changed; the layout menu shows Save and Reset at once and
  inserts the server's layouts above Reset when they arrive.
- Tiles asking for the same stream share one connection. moduleApi.sse()
  now subscribes to a shell-wide pool with one EventSource per URL, fanning
  events out to every subscriber and reconnecting on the same 3-second
  timer while anyone listens; a tile joining an open stream is told `open`
  straight away, so its backfill runs as before. Six tiles of three modules
  now hold three connections instead of six. Modules see no difference.
- /api/modules and /api/layouts fetches give up after 8 s instead of
  pending forever, so nothing awaiting them can hang.

Verified in the browser pane against a scratch server: the same six-tile
layout now opens the picker instantly with all five modules listed and
/api/modules completes; with the pool deliberately starved (three extra
EventSources) the picker still opens from its cached list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:03:48 -04:00
Waters ChurchandClaude Fable 5.1 bf1cabbc6b Timers: every timer and clock in the room, from any source
The Timers module becomes the one place a production tech looks for every
timer and clock: ProPresenter's timers as before, the LTC timecode, a
time-of-day clock (the Clock module's format / seconds / date, folded in as
a sub-part — modules/clock itself is untouched), and timers other modules
offer over the module guide's provider contract: PCO Plan's service /
current item / countdown, or anything else declaring provides: ["timers"].

Server: a small `sources` abstraction. Each source turns its own world into
the guide's standard timer objects and reports health — ProPresenter (the
existing poller, wrapped, behind ppEnabled), LTC (the existing listener,
untouched, as a kind:"clock" timer whose status.text is the timecode, with
fps/df/ageMs in an `ltc` extension), Clock, and one module source per
provider found in the shell's /api/modules over loopback (shell.port):
/timers/stream when it answers as SSE, else /timers polled every 2 s;
re-discovered every 30 s; backoff 3 s → 15 s; a provider's timers survive a
10 s grace and then go. PCO Plan sits behind pcoEnabled, the rest behind
moduleTimersEnabled. The module is itself a provider: GET /timers and
GET /timers/stream serve the merged set. The header comment says how to
add a platform in a dozen lines.

Client: one tile, every timer, grouped under source headings — label, the
big time derived locally from the snapshot and a server-offset-corrected
clock (so digits tick smoothly between snapshots), a status line, a detail
line. Countdowns count down, elapsed count up, clocks show the time; the
LTC card still counts frames. Feedback colours (running / warning near the
end / overrun / idle) are per-tile colour settings applied as custom
properties on the tile root, so they win over dashboard themes; left at
their defaults they follow the theme. Gear settings: Clock, ProPresenter
timers, LTC timecode, Module timers, Source headings, Status lines, the
clock's format / seconds / date; a "Timers ▾" header menu hides any single
timer (source:id, saved with the layout; the old hiddenItems and
timer:<uuid> / ltc picker variants keep working).

Manifest: 1.3.0, proddash >=1.4.0, provides timers, configGroups with a
toggle switch per integration (ProPresenter / LTC Timecode / PCO Plan /
Other modules) and group help; switch labels are nouns.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:03:30 -04:00
Waters ChurchandClaude Fable 5.1 49b7e46d0c Merge 184985b (shell 1.4.0 tip: admin group help as ⓘ, group toggle) into pco-plan-service-timers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:02:29 -04:00
Waters ChurchandClaude Fable 5.1 377e0b1710 Merge a6dc2dd (shell 1.4.0: shell.port, provides, module-guide contracts) into pco-plan-service-timers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 12:58:26 -04:00
Waters ChurchandClaude Fable 5.1 48526934bb pco-plan: per-service timing, ProPresenter cues, timers for the Timers module
The service clock now belongs to one service at a time: a plan's services
take turns by window (the next one takes over pre-roll before its start,
once the previous one has ended or was due to end), and moving into the
next window resets the service elapsed clock, the item timing and the
countdown. A service starts at its scheduled time or, under the new
"Service timing" admin group, when a chosen ProPresenter document goes
live (scheduled time as the failsafe); it ends when the chosen end
document goes live, or at the next service / plan end + 60 min.

The pure logic lives in service-timing.js and matching.js (cue documents
are matched like plan items, with a stricter 0.75 bar so the pre-service
loop never ends a service) with service-timing.test.js covering windows,
precedence and failsafes. GET /timers and /timers/stream offer the
service, current-item and countdown timers per the module guide;
GET /documents lists the documents the Now/Next feed has shown or listed
for the new selects. Loopback calls use shell.port, with the old
proddash.json guess only when the shell hands no port.

Manifest: 1.4.0, proddash >=1.4.0, provides ["timers"]; Client ID
wording, shorter labels, group help tightened, field help trimmed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 12:58:12 -04:00
Waters Church 184985b43e Merge branch 'claude/admin-info-toggle-themes' into claude/proddash-app-tweaks 2026-09-13 12:57:55 -04:00
Waters ChurchandClaude Fable 5.1 4dde529162 Admin: group ⓘ, group toggles in the title row, status readouts, Theme section
configGroups[name].help now renders as an ⓘ beside the group title (.group-info / .group-info-btn / .group-info-pop): a real labelled button — click pins a popover with the text, hover and focus peek at it, Escape or a click elsewhere closes it, and inside a collapsed group's <summary> it never folds the group. The paragraph above the fields is gone; per-field help stays one small line under its control.

configGroups[name].toggle names a switch/boolean field of the group: it moves into the title row, right-aligned (.field-group-toggle), as the group's on/off. While off the body dims to 0.45 (.field-group.is-off) but every field stays editable; the field is not repeated in the body, is saved exactly as before and still drives showWhen. Works with collapsed (the switch never folds the row) and columns. A toggle naming a non-boolean field is ignored.

The module card's status line is a .module-status readout: 1px --border, a background a shade lighter than the card, monospace, small radius and padding, with ok / connecting / error as the text (and border) colour.

New Theme section near the top: five swatches (.theme-swatch with a miniature bg/panel/accent preview), the current one marked, click POSTs /api/admin/theme and applies at once, errors show on a status line; other admin pages' changes arrive live over /api/events, and the login card already wears the server's theme.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 12:55:55 -04:00
Waters ChurchandClaude Fable 5.1 f79e8abd92 Themes: five presets, chosen in admin, applied live to every dashboard
Server: one THEMES list; `theme` is read from the data directory's proddash.json like port/adminPasscode; GET /api/theme is public (dashboards need it before anyone logs in) and returns { theme, themes: [{ id, name, colors: { bg, panel, accent } }] }; POST /api/admin/theme { theme } validates against the list, writes the key into proddash.json keeping every other key, updates the in-memory value and broadcasts a `theme` shell event over /api/events.

style.css: four html[data-theme] variable sets appended at the end — Harbor (navy/cyan), Graphite (charcoal/blue), Ember (warm black/orange) and Daylight (light, for daytime FOH) — each swapping exactly the eight palette variables; :root stays Booth, the default.

shell.js: a self-contained theme section — the last-known theme (localStorage proddash:theme) goes on synchronously, then the server's answer, then live changes via the SSE event; one listener line added in subscribeEvents. Tile-configured colours are set on the tile and still win.

README: a Themes subsection under Configure and the `theme` row in the proddash.json key table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 12:55:36 -04:00
Waters ChurchandClaude Fable 5.1 a6dc2ddb85 Pin the contracts the next round of work meets at
Four pieces of work are about to run in parallel — the shell, the admin
page, the Timers module and the PCO Plan module — and three of them meet at
things that didn't exist yet. This writes those down first, so they are built
once, the same way.

- **Offering timers to the Timers module.** A module declares
  `"provides": ["timers"]` and serves `GET /timers` (a cheap snapshot with
  startedAt/elapsedMs/targetMs, an optional status line, optionally an SSE
  stream). The consumer extrapolates locally, so no fast polling. Modules meet
  over loopback through each other's routes, as PCO Plan already does to
  follow ProPresenter.
- **Admin group controls.** `configGroups[name].help` becomes an ⓘ beside the
  group title rather than a paragraph under it; `configGroups[name].toggle`
  names a switch that renders in the title row as the group's on/off.
- **Switches, labelled with nouns.** On means shown or enabled; the label
  shouldn't say so twice.
- **Themes** swap the shell variables; a tile's own configured colour is set
  closer and wins.

Two shell hooks make it real: `/api/modules` forwards `provides`, and module
servers get `shell.port` in init/routes/tiles instead of guessing it from
proddash.json.

Clock: descriptions gone, "Show seconds" is "Seconds" (1.0.1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 12:32:31 -04:00
Waters ChurchandClaude Opus 5 7b353cf7f8 Shell: a seam drag moves the whole column, not one pair
Dragging a seam moved exactly the two tiles touching it, so a column edge
shared by several rows had to be dragged row by row and drifted out of line
in between.

A drag now moves that column boundary wherever it appears: every pair of
tiles meeting at the same x, in every row, travels with it. Each pair still
trades width within its own fixed span, so no row can push into another, and
the whole column stops at whichever row runs out of room first — one row's
minimum width holds the line for all of them. All rows move or none do; a
column edge that bends halfway is worse than one that won't budge.

Rows are only grouped by a shared seam. A row split somewhere else keeps its
own boundary, and a row where one tile simply spans across has no seam there
and stays put. Hovering a seam lights up the others that travel with it, so
what is about to move is visible before the drag starts.

Verified in the browser: two rows split at the same column move together from
one drag; rows split at different columns stay independent; a tall tile beside
two stacked ones resizes all three; and a 4-column drag stopped at 1 because
the lower tile's minimum width said so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 18:52:14 -04:00
Waters ChurchandClaude Opus 5 804dc483d6 Shell: draw the bottom resize handle like the corner ones
It was a filled rounded pill among two corner brackets drawn as 2px lines —
three marks along the same edge in two different languages. Now it is a 2px
line in currentColor on the same 4px inset, with the same muted-to-text
hover, so the bottom edge reads as one set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 18:45:20 -04:00
Waters ChurchandClaude Opus 5 fb66d56f7d Shell: a quieter menu handle, a calmer fullscreen, live tile settings
Three things the dashboard was making people work for.

**The menu handle.** It was the ProdDash wordmark on a pill hanging past the
bar's bottom edge, which read as a step cut out of the bar. The wordmark goes
back in the bar where it belongs, and the handle becomes a small accent lip on
the bar's bottom edge — the same idea as a tile's notch — that grows under the
pointer so it stays easy to hit without being loud at rest.

That also fixes the animation Safari made a meal of: the old handle
transitioned font-size and line-height, animating text metrics frame by frame
on the way out. Nothing here animates text. The lip rides `--topbar-h`, the
bar's live height, so it tracks the bar's own collapse instead of running a
second animation against it, and its hover growth is width, height and
opacity only.

**Fullscreen.** Fullscreen is for watching, not arranging, so every tile's
title bar now folds away on the way in. A notch still peeks one open, and it
folds back ten seconds after the pointer leaves that tile — coming back to the
tile cancels the countdown. Peeking is a transient class, so a tile's own
hidden/shown preference is untouched and comes back with the screen.

**Tile settings.** Gear settings are display choices — a text size, a filter,
a colour — so they apply as they are changed and save with the layout. Apply
and Cancel are gone; close by clicking away, pressing Escape, or the gear
again. Typing is debounced so a remount doesn't land on every keystroke, and
an unchanged value remounts nothing (a text field fires input and then change
for one edit). The module guide says so now too.

Verified in the browser: settings apply live and persist, no-op changes cost
nothing, all three close paths work, and in fullscreen both heads fold, one
peeks, it folds back at ten seconds, returning the pointer cancels it, and
leaving fullscreen restores everything with no peeks left behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 18:40:31 -04:00
Michael McGary 5d55609fbe Merge pull request #13 from michaelmcgary2008/claude/proddash-menubar-launcher-6eb34e
ProdDash for macOS: one self-contained, notarized app
2026-09-11 13:38:57 -04:00
Waters ChurchandClaude Opus 5 adac3a4785 Ship ProdDash as one app: its own Node, its own copy, a .dmg
Installing is dragging ProdDash to Applications. The app carries the
dashboard and the Node that runs it, so nothing has to be installed first and
nothing can go missing underneath it.

  ProdDash.app/Contents/
    MacOS/ProdDash            the launcher
    Resources/node/bin/node   Node 24.21.0 LTS, fetched and checksum-verified
    Resources/app/            server.js, public/, modules/

Everything mutable stays where it already was, in ~/Library/Application
Support/ProdDash — and that now includes app/, the copy of ProdDash that
actually runs. The copy is not ceremony: ProdDash updates itself in place,
and nothing may write inside a signed .app. So the bundle holds a read-only
baseline and the data directory holds the running copy, which means both
routes to a new version keep working and don't fight — installing a newer
.app replaces the copy, while a copy that has already updated itself past the
bundle is left alone.

build.sh now assembles all of it: fetches Node against nodejs.org's published
checksums (cached in .cache/), embeds the server, compiles each module's
native helper so the Mac running ProdDash needs no compiler, signs nested
code before the bundle, and builds the disk image. --dev skips the payload
for launcher work, which still runs against a checkout.

Signing is set up for a Developer ID with the hardened runtime and
notarisation (--notarize), and falls back to ad-hoc when no such certificate
is installed. Entitlements are split by what each binary actually does: JIT
for node, audio input for the launcher and for module helpers that read one.

Built and run end to end: first-run install, bundled node, dashboard and
admin serving, re-seed in both directions (newer app replaces an older copy;
an updated copy survives), disk image mounts and its app verifies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:38:46 -04:00
Waters ChurchandClaude Opus 5 e1bceb3e60 Launcher: strip the window back to what's worth reading
A pass for less prose and fewer boxes.

- Header shows the version and node; the port is already in the addresses
  below it, and the module list belonged in the log.
- Settings: "Network" over the port (no explanation under it), "Startup"
  instead of "Starting up", and no node chooser — the path stays visible,
  since which node runs is the first thing worth knowing when it won't.
- Permissions: one line per permission — a status dot, its name, and the one
  thing you can do about it. The standing explanation is gone and the
  modules' own reasons moved to each row's tooltip. Local network results
  still appear under their row after a check, with a short line and a link
  only when nothing answered at all. Re-check sits centred at the bottom,
  outside the group.

The app-level "notify me" switch goes with it: macOS's own per-app
notification setting already does that job, so notifications now follow the
permission alone, asked for by the row's Enable button rather than on first
run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:10:22 -04:00
Waters ChurchandClaude Opus 5 1dbd55aa24 macOS menu-bar launcher: run ProdDash, and own its permissions
A native Swift app (launcher/macos, ~2 MB, no dependencies) that starts,
stops and watches over the server from the menu bar — and holds the macOS
permissions its modules need.

The permissions are the point as much as the buttons are. macOS grants
privacy permissions to the application that owns a process, and a child
inherits its parent's identity, so with the server started from here node —
and the Timers module's ltc-capture — belong to this app bundle and the
microphone and local-network prompts are asked, and remembered, as ProdDash.
Started over SSH or from a launchd job there is nobody to ask: the permission
is silently absent rather than refused, which is the "endless zeroes" failure
ltc-listener.js warns about.

The shell was already built for a supervisor: with PRODDASH_LAUNCHER=1 it
exits 75 instead of respawning itself when the admin page applies an update,
which the launcher reads as a restart request rather than a fault. Crashes
get a backed-off restart (1s, 2s, 5s, 10s) and four in two minutes stops the
cycle. The server is spawned into its own process group, so stopping it also
stops anything it started — an orphaned ltc-capture would otherwise hold an
audio device indefinitely — and signals, a logout and Quit all stop it first.

- Menu bar: ProdDash's own 2x2 tile mark, its accent tile lit green while
  serving, amber mid-move, red when something needs a person. Start, stop,
  restart, open the dashboard or admin, open at login, quit.
- Window: status and both addresses, plus Log (also written to
  ~/Library/Logs/ProdDash, kept a week), Settings, and Permissions.
- The port is written to the data directory's proddash.json — ProdDash's own
  file — so the port set here holds however it is next started. Something
  already on the port is reported, and offered as a takeover when it answers
  like a ProdDash started another way.
- Modules declare what they need in their manifests ("permissions": [...],
  documented in docs/MODULE-GUIDE.md and ignored by the shell); the launcher
  lists exactly that, with each module's reason, asks for the microphone,
  and — since macOS reports nothing about local network access — offers a
  check that browses for services and tries every configured upstream.

Timers, ProdCom and ProPresenter modules declare their permissions (patch
bumps). Build with launcher/macos/build.sh; output is gitignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:27:08 -04:00
Michael McGary abc70aa53d Merge pull request #12 from michaelmcgary2008/claude/prodcom-orientation-channels-icon-bcf897
ProdCom: direction toggle, icon channels button, leaner tile settings
2026-09-10 16:49:17 -04:00
Michael McGary a50709b6ff Merge pull request #11 from michaelmcgary2008/claude/pco-plan-module-f711b9
PCO Plan module: Planning Center order of service, following ProPresenter
2026-09-10 16:47:25 -04:00
Waters ChurchandClaude Opus 5 5ecc39efe2 ProdCom: direction toggle, icon channels button, leaner tile settings
Three changes to the ProdCom tile, all per-tile and persisted with the
layout (version 1.2.0):

- A title-bar button flips the transcript between top-down (newest at the
  bottom, still the default) and bottom-up. It is CSS only — the entries
  container becomes column-reverse, DOM order is untouched — but everything
  that follows "latest" flips with it: auto-follow, jump-to-latest, the
  near-latest scroll test, and the "New messages" pill, which moves to the
  top of the tile and points up.

- The "Channels ▾" text button is now a funnel icon. It keeps what the
  label carried: while any channel is hidden the icon takes the accent
  color and a compact "3/4" count sits beside it, with the full wording in
  the tooltip.

- Tile settings drop the channel-name filter and text size, both of which
  the title bar already handles, and gain "Show channel icons" for the
  per-entry avatars. Removing the channel filter made its code dead, so
  channelFilter(), filteredOut() and the data-channel-name attribute went
  with it — the menubar's hidden-channel set is now the only filter.

Text size keeps working without a schema entry: the A−/A+ buttons persist
it through saveInstanceSettings, the shell's settings popover only writes
schema keys so it survives an Apply, and the client falls back to 15px when
it is absent. Existing tiles have no saved showAvatars value and the check
is `=== false`, so they keep their icons.

Verified against tools/prodcom-mock.js: both directions render and
auto-follow correctly, the pill appears at the right end and jumps to the
newest entry, the count badge tracks the channel toggles, a tile bumped to
19px kept it across an Apply and a reload, a freshly added tile started at
15px with icons shown, and two tiles held independent state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:40:46 -04:00
72 changed files with 18163 additions and 1691 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "start",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"start"
],
"port": 3000
}
]
}
+7
View File
@@ -10,3 +10,10 @@ config/layouts/
# compiled per-machine, source sits next to it
modules/propresenter-timers/ltc-capture
# the launcher's build output, and its cache of the Node runtime it embeds
launcher/macos/build/
launcher/macos/.cache/
# Compiled per-machine, source sits next to it (see spl-capture.swift).
modules/spl-meter/spl-capture
+17 -6
View File
@@ -3,11 +3,15 @@
Modular production dashboard web app for Waters Church, developed in this repo.
The app lives at the repo root and is built: zero-dependency Node server
(`server.js`, Node 18+), dashboard shell client in `public/`, admin page at
`/admin`, modules in `modules/`. Runtime state (module config, named layouts)
is written to a per-machine data directory outside the checkout
(`PRODDASH_DATA_DIR` overrides; see README "Where settings are kept") — never
add code that writes runtime state into the repo folder. [SPEC.md](SPEC.md) was the authority for the
initial build; [README.md](README.md) has run/configure/use instructions.
`/admin`, modules in `modules/`, and a native macOS menu-bar launcher in
`launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
the server and its own Node runtime, supervises them, and owns the macOS
permissions modules declare; see its README). Runtime state (module
config, named layouts) is written to a per-machine data directory outside the
checkout (`PRODDASH_DATA_DIR` overrides; see README "Where settings are
kept") — never add code that writes runtime state into the repo folder.
[SPEC.md](SPEC.md) was the authority for the initial build;
[README.md](README.md) has run/configure/use instructions.
## Building a new module — read docs/MODULE-GUIDE.md first
@@ -28,18 +32,25 @@ title-bar controls). The rules that matter most:
and bump the module's `version` on every change.
- Browsers never call upstream services directly; the module's server routes
proxy everything (no CORS, secrets stay server-side).
- **Keep the interface clean** (guide rules 5-11): no `help` and no label
over three words in a tile's gear; server-side settings in `configSchema`
only; a tile setting changes that tile alone; hide-toggles ungrouped when
there are four or fewer, in a folded **Show** group when there are five or
more; title-bar controls are icons with tooltips, never labels, and never
repeat something the gear already offers.
- Style with the shell's CSS variables only, scoped under
`[data-module="<id>"]`; survive upstream loss (reconnect + `setStatus`).
- Verify against the guide's testing section and resilience checklist. Mocks
for the existing upstreams: `node tools/prodcom-mock.js` (ProdCom) and
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter).
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
## Reference code (read-only forks — don't develop these here)
- `prodcom-listener/` — live ProdCom transcript web app. Zero-dependency Node
`server.js` that serves `public/` and proxies `/prodcom/*` (including the SSE
transcript stream) to the ProdCom API at `config.json → prodcomUrl`
(default `http://10.3.11.152:24480`). Good reference for: SSE handling,
(set per site; each campus runs its own). Good reference for: SSE handling,
reconnect logic, channel colors/filtering, PWA install.
- `band-lineup-display/` — backstage Electron app (stage lineup photos with
mic/pack labels) plus ProPresenter Now/Next:
+174 -27
View File
@@ -15,9 +15,10 @@ touching the shell (see [docs/MODULE-GUIDE.md](docs/MODULE-GUIDE.md)).
2. Discovers modules in `modules/` and serves each one's client at
`/modules/<id>/`.
3. Mounts each enabled module's server routes under `/api/modules/<id>/` —
that's where module servers proxy ProdCom, poll ProPresenter, and stream
live updates over SSE, so browsers never talk to production gear
directly (no CORS, and API keys never leave the server).
that's where module servers follow ProdCom, poll ProPresenter, and stream
live updates over SSE — one upstream connection per module, however many
dashboards are open — so browsers never talk to production gear directly
(no CORS, and API keys never leave the server).
4. Stores server-wide module config and named layouts in a per-machine data
directory outside the app folder, so updating ProdDash never loses them.
@@ -29,6 +30,27 @@ and a clear degraded state, then recover on their own.
## Run it
### macOS — the app
Open the disk image and drag **ProdDash** to Applications. That's the whole
installation: the app carries the dashboard and its own Node runtime, so
nothing else has to be installed and nothing can be missing. It lives in the
menu bar, starts and stops the server, can open at login, and holds the
permissions the modules need — the microphone the LTC listener reads, and
local network access for ProdCom and ProPresenter. macOS grants those to the
app that owns the process, so a server started any other way (over SSH, from
a launchd job) simply doesn't have them, and an LTC input goes silently to
zeroes.
Build the disk image from a checkout with
[launcher/macos](launcher/macos)/`build.sh`:
```bash
cd launcher/macos && ./build.sh --dmg
```
### Anywhere else, or from a checkout
Requires [Node.js](https://nodejs.org) 18 or newer.
```bash
@@ -67,17 +89,44 @@ it at startup (`Settings : …`).
The directory holds `modules.json` (module config), `layouts/` (named
layouts) and `modules/` (modules installed from the admin page), all written
by the app itself. It may also hold a `proddash.json`
by the app itself. Installed from the macOS app, it also holds `app/` — the
copy of ProdDash that actually runs, laid down from inside the app bundle on
first launch and updated in place from then on (nothing may write inside a
signed .app, and ProdDash updates itself). It may also hold a `proddash.json`
with this machine's shell settings — the same keys as the checked-in
[config/proddash.json](config/proddash.json), which supplies the defaults:
| Key | Meaning |
| --- | --- |
| `port` | Port ProdDash serves on (default `24500`) |
| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. |
| `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. |
| `adminPin` | The admin PIN, as a scrypt hash. **Set it in the admin page's Admin PIN section**, not by hand — 6 digits, and only the hash is stored. Absent means admin is open to anyone on the network. |
| `adminPasscode` | An older free-text passcode. Still honoured, so an existing install keeps working; new installs should use `adminPin`. |
| `forge` | Where updates and module installs are fetched from. Absent or `"github"` uses GitHub; a Gitea or Forgejo base URL such as `"http://10.3.8.90:3000"` uses a server of your own. Set it per machine in the data directory, alongside `repo`. |
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember`, `daylight`, `midnight`, `moss`, `plum`, `sepia`, `blackout`, `paper` or `iron`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
**Locked out?** Delete `adminPin` from the data directory's `proddash.json`
and restart, or start the server with `PRODDASH_PASSCODE` set — that passcode
overrides the PIN and always gets you in.
Only `/admin` and the admin API are behind the PIN. Dashboards stay open, so a
wall display needs no PIN, and it is unrelated to a module's own PIN (Slack's
4–8 digit send PIN, say), which guards that module's actions only.
**Updating from your own git server.** ProdDash fetches one `tar.gz` of the
branch — nothing else — so any Gitea or Forgejo works. Point `forge` at its
base URL and `repo` at the owner/repo there:
```json
{ "forge": "http://10.3.8.90:3000", "repo": "michael/ProdDash", "branch": "main" }
```
Gitea spells an archive URL differently from GitHub, which is the only thing
that changes; the tarball itself is identical, commit hash included, so update
detection and module installs behave exactly as before. Make the repository
readable without signing in, or ProdDash cannot fetch it.
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
`PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path
`PRODDASH_FORGE`, and `PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path
is taken from the app folder; `PRODDASH_DATA_DIR=config` restores the old
in-repo location). If the default directory can't be created, the server says
so and falls back to the app's `config/` folder.
@@ -87,12 +136,43 @@ so and falls back to the app's `config/` folder.
directory, so nothing has to be re-entered. The old files are left in place and
ignored from then on.
### Themes
The whole dashboard — every tile, on every browser — draws with one palette,
chosen in the admin page's **Theme** section and switched live: pick a swatch
and open dashboards change without a reload. The section starts folded —
a building picks its palette once — and holds twelve presets:
| | |
|---|---|
| **Booth** | the default dark, mint accent |
| **Harbor** | deep navy, cyan |
| **Graphite** | neutral charcoal, electric blue |
| **Ember** | warm broadcast black, orange |
| **Daylight** | a cool light palette, for a front-of-house desk in daytime |
| **Midnight** | indigo night, violet |
| **Moss** | forest floor, lime |
| **Plum** | aubergine, purple |
| **Sepia** | warm brown room, cool teal — Ember's arrangement reversed |
| **Blackout** | true black, cyan, for a dark room where any glow carries |
| **Paper** | the warm light one, indigo accent |
| **Iron** | no colour of its own: a near-white accent, so the only hues on screen are a module's own and the red and amber that mean something |
Each keeps the status colours apart — the accent is never near the danger red
or the warning amber — and muted text readable in a dark booth. The choice is
stored as `theme` in the data directory's `proddash.json` (table above), so
it holds across restarts and updates; a browser remembers the last theme it
saw and applies it before the server answers, so nothing flashes. Modules
style with the shell's colour variables, which is why they follow; a colour
set on a tile itself (a PCO Plan header colour, say) is deliberately kept.
## Installing modules and updating
Everything below happens in the **admin page**; ProdDash talks to its GitHub
repository (`michaelmcgary2008/ProdDash`, branch `main` — change with
`repo` / `branch` in `proddash.json` or the `PRODDASH_REPO` variable,
`owner/repo#branch`). It downloads one small archive of the branch, at most
Everything below happens in the **admin page**; ProdDash talks to its git
repository — `michael/ProdDash` on the church's own Gitea at
`chronicles-ab.waterschurch.org:3000`, branch `main`. Change it with `forge`,
`repo` and `branch` in `proddash.json`, or the `PRODDASH_FORGE` and
`PRODDASH_REPO` variables (`owner/repo#branch`). It downloads one small archive of the branch, at most
every few minutes, only while the admin page is open or for the update check
below. Nothing is installed without someone clicking.
@@ -120,7 +200,18 @@ below. Nothing is installed without someone clicking.
no local changes — otherwise the banner says why and you update with git).
A plain folder (no `.git`) has the repo archive unpacked over it; files
removed upstream are left behind but harmless. Settings and layouts are
never touched. **What changed** opens the commit comparison on GitHub.
never touched — nothing inside the data directory is written, even on an
install where that directory *is* the app's own `config/`. **What changed**
opens the commit comparison on the forge.
- **If an update goes wrong**: the download has to parse before anything is
swapped in, so a truncated or mangled `server.js` is refused with the
reason and the running copy is left exactly as it was. Past that, the copy
being replaced is set aside as `app.previous` next to the app folder, and
dropped once the new version has stayed up for ninety seconds — so that
folder existing means "the last update has not proven itself yet". The
macOS app watches for a version that will not stay up (four stops in two
minutes) and puts the old one back by itself, once, with a notification.
Running some other way, the way back is a rename: `mv app.previous app`.
- **Restarting**: after an update the server exits with code 75 and starts
its own replacement, which waits for the port to free up. If you supervise
ProdDash with your own launcher, set `PRODDASH_LAUNCHER=1` so the server
@@ -132,13 +223,17 @@ below. Nothing is installed without someone clicking.
- **+** — add any enabled module; multiple tiles of the same module are
fine (e.g. two transcripts filtered to different channels).
- Drag a tile by its **title bar** to move it; drag either **bottom
corner** to resize; **✕** removes it. Hover a tile and a small pill
appears on its **bottom edge** — drag it to change just the height. If
another tile sits directly below, the pill moves the shared edge instead,
trading rows between the two so the column keeps its total height. Tiles
sit flush — when two share a vertical edge, hover the seam and the same
kind of pill appears; drag it to trade width (macOS split-view style).
- Drag a tile by its **title bar** to move it; **✕** removes it.
- **Resize from any edge or bottom corner**, the way a window resizes:
nothing is drawn on a tile to mark the spots — the cursor over an edge is
what says it can be dragged. An edge moves only itself, so the tile grows
or shrinks into the space beside it while the opposite edge stays put,
stopping at a neighbour or at the module's own minimum size. Two
exceptions, both of which move a *shared* edge instead: with another tile
directly below, the bottom edge trades rows between the two so the column
keeps its total height; and where two tiles meet side by side, the seam
between them trades width (macOS split-view style), showing itself only
while you drag it.
- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its
green lip at the top-left brings it back, minus the Admin button), the
occupied columns stretch to the full width, and every column runs to the
@@ -148,9 +243,21 @@ below. Nothing is installed without someone clicking.
gap; alone in its columns it stays as it is. Nothing is saved — the layout
reverts when you leave fullscreen. A layout deeper than the screen keeps
its normal row height and scrolls.
- A tile's actions (menus, text size, clear…) live in its **title bar**;
the small **notch** on the bar's bottom edge hides/shows the whole bar
for a clean wall-display look. The menu bar's handle is the **ProdDash
- A tile's actions (menus, text size, clear…) live in its **title bar**.
- **Auto-hide bars** (the button left of ⛶) folds every title bar away, for
a windowed screen that should read like a wall display. A folded bar is
not a gone bar: point at the tile's top edge and it slides back in over
the content, where its buttons click and its width drags the tile exactly
as before — so a dashboard can be arranged with every bar out of the way.
The switch is off on a fresh dashboard and saves with the layout —
including a named layout on the server, so a booth loads its screen with
the bars already tucked away. Fullscreen folds every bar regardless, and
the same hover brings one back there too.
- The **pin** beside a tile's title keeps *that* tile's bar showing while
auto-hide is on — for the one or two tiles whose controls are wanted at
a glance. It turns the accent colour when it is on and saves with the
layout. Fullscreen still folds a pinned bar with the rest.
- The menu bar's own handle is the **ProdDash
name itself**, wrapped in a translucent green pill at the left — click it
to hide the bar; the slim green lip it leaves at the top-left edge brings
the bar back. It stays at the left so it never sits on top of a tile's
@@ -159,7 +266,14 @@ below. Nothing is installed without someone clicking.
display options) — these belong to this browser's layout, not the server.
- The **grid icon** — save the current arrangement to the server under a
name, load a named layout (it becomes a local copy — browsers are never
linked), or reset this browser's dashboard.
linked), or reset this browser's dashboard. Loading says what it did —
*Loaded "FOH": 9 tiles, 2 not shown — Service, #ops aren't offered right
now* — with **Undo** in the message to get back the arrangement you had.
A tile whose module or picker entry isn't offered at the moment keeps its
place and shows nothing, then appears when it is; it is never replaced by
the module's default tile. Saving over an existing name asks first, and
the server keeps the copy it replaced: **Restore previous** in Admin →
Saved layouts swaps it back (and swapping again undoes that).
- Every tile has a **status dot**: green = live, amber = connecting,
red = upstream unreachable (hover it for details). Tiles reconnect
automatically.
@@ -168,11 +282,19 @@ below. Nothing is installed without someone clicking.
- **ProdCom** — live transcript stream with in-place updates of in-progress
speech, channel colors, per-tile channel visibility toggles and group
presets, timestamps, text size and jump-to-latest in the title bar.
presets, timestamps, text size, a top-down / bottom-up direction toggle
and jump-to-latest in the title bar.
Admin config: ProdCom server (host : port), API key.
Tile settings: channel icons, direction and timestamps.
- **ProPresenter** — what's live and what's next, with slide progress,
driven by the shared `propresenter-core` client (ProPresenter 7/20).
The + picker offers two tiles: **Now / Next**, the full card, and **Now**,
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
its item tags, plus tidy titles.
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
discovered timer (and the LTC card) as its own tile, or add "All timers"
@@ -206,6 +328,29 @@ below. Nothing is installed without someone clicking.
(gear menu): the top-bar text (placeholders such as
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
show/hide checklist for every detail.
- **Slack** — live transcripts of this location's Slack channels, plus
PIN-protected sending as the location. The + picker lists one transcript
tile per configured channel, titled `#channel`, and two send surfaces (one
of each per dashboard): **Send message**, a free-text box with the admin's
quick emojis one press away, and **Quick replies**, a grid of preset
buttons. Both post to the channel of whichever transcript was clicked last
— its bottom edge lights up in the accent color — and to the first
configured channel until one is clicked. Messages arrive with avatars,
names and times, Slack's inline formatting and the workspace's custom
emoji; a right-click on one opens the reaction menu, and text size and
jump-to-latest sit in the title bar. Nothing leaves the machine until the
PIN is entered: one PIN unlocks every Slack tile on that dashboard page
until the auto-lock idles out, a reload asks again, and the lock button on
a send tile's title bar ends it early.
Admin config is in four groups: **Slack account** (the location account's
User OAuth Token — `xoxp-…`, so messages are literally from the campus —
or a bot token plus "Send as"), **Channels** (the joined channels to
follow, one transcript tile each), **Transcript** (refresh interval,
messages kept, thread replies) and **Sending** (the switch on the group's
title row, a 4–8 digit PIN, auto-lock minutes, quick replies one per line
as `Label | Message` — add `| #channel` to bind one to a channel — and up
to eight quick emojis).
Tile settings: avatars, timestamps, names and channel notices.
- **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors.
@@ -213,10 +358,12 @@ To develop modules against mocks instead of real gear:
`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`),
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on
`127.0.0.1:1599`), `node tools/pp-timers-mock.js 1600` (ProPresenter
timers + timecode on `127.0.0.1:1600`) and `node tools/pco-mock.js`
timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js`
(Planning Center Services on `127.0.0.1:24700` — run ProdDash with
`PCO_PLAN_API_BASE=http://127.0.0.1:24700`). See the testing section of the
[module guide](docs/MODULE-GUIDE.md).
`PCO_PLAN_API_BASE=http://127.0.0.1:24700`) and
`node tools/slack-mock.js 24716` (the Slack Web API on `127.0.0.1:24716` —
run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`). See the
testing section of the [module guide](docs/MODULE-GUIDE.md).
## Install as a desktop web app
+1 -1
View File
@@ -129,7 +129,7 @@ modules/<id>/
"minSize": { "w": 2, "h": 2 },
"defaultSize": { "w": 4, "h": 3 },
"configSchema": {
"prodcomUrl": { "type": "string", "label": "ProdCom URL", "default": "http://10.3.11.152:24480" },
"prodcomUrl": { "type": "string", "label": "ProdCom URL", "default": "" },
"apiKey": { "type": "password", "label": "API key (if PSK auth enabled)", "default": "" }
},
"instanceSchema": {
+2 -1
View File
@@ -1,6 +1,7 @@
{
"port": 24500,
"adminPasscode": "",
"repo": "michaelmcgary2008/ProdDash",
"forge": "http://chronicles-ab.waterschurch.org:3000",
"repo": "michael/ProdDash",
"branch": "main"
}
+282 -41
View File
@@ -34,6 +34,46 @@ people running the booth depend on them.
The admin page refuses to install a module whose requirement the running
shell doesn't meet, and it offers updates by comparing versions.
### Keeping the interface clean
A dashboard is read across a booth, at a glance, by someone doing three
other things. These rules are what keep every tile looking like one product
instead of six. They are as binding as the four above.
5. **No explanatory text in a tile's gear.** No `help` on an
`instanceSchema` field or an `instanceGroups` entry. Labels are **three
words at most, one or two preferred** — "Slide count", "Item tags",
"Running clock". A control that cannot be understood in three words is
not a display choice; it belongs in `configSchema`, where `help` is
welcome and expected. *One exception:* a `template` field may keep a
single `help` line naming its placeholders, which nobody can guess.
6. **Server-side settings are admin-only.** Anything the module's server
acts on — a host, a credential, what to fetch, how often to poll — is
`configSchema`. `instanceSchema` is display choices and nothing else:
what this tile shows, and how big.
7. **A tile setting changes that tile and nothing else.** Never another
tile, never the module as a whole, never the server. If two tiles must
agree on something, it is either admin config or page state the shell
owns — not a setting each tile keeps its own copy of.
8. **Offer toggles for the elements you draw.** Every tile should let the
operator switch off the parts it does not need. **One to four toggles:**
give them no `group` at all, so they sit unlabelled at the top of the
gear. **Five or more:** one group named **Show**, `collapsed: true`,
`columns: 2`. Nothing else goes in that group — colours, formats and
text belong in groups of their own.
9. **Title-bar controls are icons.** No `label` on `addButton` or
`addMenu`; the tooltip carries the words, and `docs` above covers drawing
the icon. A small state indicator may ride alongside an icon — ProdCom's
channel button shows a count while a filter is on — but a control is
never named in the bar.
10. **If a control needs words to explain it, it is not a title-bar
control.** Put it in the gear. The bar is for actions whose icon and
tooltip say everything: refresh, clear, jump to latest, bigger text.
11. **One control per action.** Whatever has a dedicated title-bar button is
not offered again in the gear, and the reverse. Text size is A− / A+ up
there, never a "Text size" field; a timestamps button in the bar means
no timestamps switch below it.
## The shape of a module
A module is one folder dropped into `modules/`:
@@ -66,6 +106,9 @@ the app); its settings are kept for a later reinstall.
"version": "1.0.0",
"proddash": ">=1.1.0",
"description": "One line shown in the Add-tile picker and the admin page",
"permissions": [
{ "kind": "microphone", "reason": "Why this module needs it, in one line" }
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
@@ -88,18 +131,20 @@ the app); its settings are kept for a later reinstall.
| `version` | yes | Your module's version (`x.y.z`). Bump it on every change — the admin page offers updates when the repo's copy is newer than the installed one. |
| `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. |
| `description` | no | Shown in the admin page / picker. |
| `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. |
| `provides` | no | Capabilities other modules can consume — `["timers"]` today. Forwarded in `/api/modules` so a consumer can find you. See **Working with other modules**. |
| `client` | yes | Client entry file, loaded as an ES module. |
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
| `style` | no | A stylesheet the shell injects once per module. |
| `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. |
| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. |
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). |
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). They apply as they are changed, so keep them to display choices. |
| `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. |
Both schemas map field names to specs:
```
{ "type": "string" | "number" | "boolean" | "switch" | "select" | "color" | "password" | "endpoint",
{ "type": "string" | "text" | "number" | "boolean" | "switch" | "select" | "multiselect" | "template" | "color" | "password" | "endpoint",
"label": "Shown next to the field",
"default": <value>,
"group": "Section heading", // optional; see below
@@ -111,10 +156,33 @@ Both schemas map field names to specs:
}
```
`boolean` renders as a checkbox; `switch` is the same true/false value shown
as a toggle (use it for a feature on/off that other fields depend on).
`boolean` and `switch` are the same true/false value. In a tile's gear popover
both render as a switch; in the admin page `switch` is the toggle and
`boolean` the plainer checkbox (use `switch` for a feature on/off that other
fields depend on). **Label a switch with the noun of what it turns on** —
"Seconds", "Top bar", "Section headers" — never "Show seconds": on means
shown or enabled, off means hidden or disabled, and the label shouldn't say
so twice.
`color` renders a swatch picker and stores a `#rrggbb` string (give it a hex
default; the tile applies it as a CSS custom property — see `pco-plan`).
`multiselect` is several picks from one list — the same `options` /
`optionsRoute` as `select`, rendered as one switch per option and stored as
an array of values. A saved value the list no longer offers stays checked and
marked, so nothing is dropped behind anyone's back. In a tile's gear popover
it also takes `columns` (2–4) and `allByDefault: true`: a tile that has never
chosen shows every option on and saves nothing until someone flips one, so
options that turn up later are on as well (give it no `default` then).
`select` and `multiselect` may use `optionsRoute` in `instanceSchema` too —
the popover GETs `/api/modules/<id><optionsRoute>` like the admin page does.
`template` (tile settings) is a text with placeholders, built by dragging or
clicking the variables the module offers into it: `"variables": [ { "value":
"{series}", "label": "Series" }, … ]`. Keep `help` to the joining rules; the
chips carry the names.
`text` is a multi-line string — a `<textarea>` in the admin page, stored with
its newlines; one item per line is the convention (the `slack` module's quick
replies). Admin config only.
`group` gathers consecutive fields under a labeled subsection in the admin
page, so a module with several distinct concerns (e.g. a ProPresenter
@@ -137,21 +205,47 @@ the group name:
still empty. Omit it and the group is a plain, always-open block.
- `columns`: 2–4 lays the group's fields out in a grid — the way to keep a
long checklist of booleans compact.
- `help`: a line of guidance at the top of the group.
- `help`: guidance for the whole group. The admin page shows it as an
ⓘ beside the group title (click or hover to read), so a group can carry a
paragraph without pushing its fields down the page. Say where a value comes
from, not what a field is.
- `toggle`: the key of a `switch` field in this group. It renders in the
group's title row, at the right, as the group's on/off; the rest of the group
dims while it is off, and the field is not repeated in the body. This is how
an integration is disabled without unmounting the module — ProPresenter
timers, say, on a Sunday when ProPresenter isn't running.
`help` on a field puts one line of guidance under its control — allowed
placeholders, where a value comes from. Keep labels short and put the
explanation there.
explanation there. **This is for `configSchema` only**: a tile's gear
carries no explanations at all (rule 5), so a setting that needs one is
telling you it belongs in admin.
`group` and `help` work on `instanceSchema` fields too, and a manifest
`group` works on `instanceSchema` fields too, and a manifest
`instanceGroups` map plays the `configGroups` role for the tile's gear
popover (`collapsed: true | false`, `columns`, `help`). That is how a module
with a long show/hide checklist keeps the popover usable — see `pco-plan`.
Display choices belong there, per tile; admin config is for connections.
popover (`collapsed: true | false`, `columns`, `toggle` — but not `help`,
per rule 5). That is how a module with a long show/hide checklist keeps the
popover usable: five or more hide-toggles go in one **Show** group, folded
by default, and fewer than five go in no group at all — see `pco-plan` for
the long form and `slack` for the short one. Display choices belong there,
per tile; admin config is for connections. The popover itself is a small window beside the tile — sized to
its content, moved by the slim bar on top, closed by clicking anywhere else
or pressing Escape — so a tile never has to be big enough to hold its own
settings, and a change can be watched as it lands. Under everything you
offer, the shell adds **Tile tint** — a colour the operator can wash that
tile's chrome in, saved with the layout. It is not yours to declare and it
never remounts you; don't offer one of your own.
A picker entry (see **Presenting multiple tiles**) may carry its own
`instanceSchema` and `instanceGroups`: tiles added from that entry get those
instead of the module's. A tile that shows one ProPresenter timer has no use
for a clock's format switches — give it a schema of what applies.
`showWhen: "<key>"` hides a field until the boolean/`switch` field named
`<key>` is on — e.g. the audio device and channel appear only once the LTC
listener switch is enabled. The hidden field still keeps its stored value.
It works the same in a tile's gear (`instanceSchema`): "Short day" and
"Short month" appear once the clock's "Date" is on.
A `select` whose choices are only knowable at runtime (audio devices, serial
ports, discovered sources) sets `optionsRoute` instead of — or as a fallback
@@ -207,6 +301,38 @@ Admin config lives in `modules.json` inside the server's data directory
survives updates of the app folder). Per-tile settings live inside each
browser's layout.
### Permissions macOS has to grant
Some things a module does need the operating system's permission, not just
configuration: reading an audio input, reaching gear on the local network.
macOS grants those to the **application that owns the process**, which for
ProdDash is the macOS launcher (`launcher/macos`) — the server is its child,
so the prompts are asked, and remembered, in ProdDash's name. A server
started over SSH or from a bare launchd job has no such owner, and the
permission is silently absent rather than refused: an LTC input reads as
endless zeroes, a local host simply never answers.
Declare what you need, and why, so the launcher's Permissions tab can list
it and ask for it before a service rather than during one:
```json
"permissions": [
{ "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" },
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
]
```
| `kind` | When to declare it |
| --- | --- |
| `microphone` | The module reads an audio input (`ltc-capture` and anything like it). |
| `local-network` | The module talks to gear on this building's network. Not needed for services out on the internet — Planning Center, say. |
`reason` is one line, shown to the operator as-is when they hover the
permission; modules giving the same reason are listed together. A bare `"permissions": ["microphone"]` works when
the standing explanation is enough. A `kind` the launcher doesn't manage is
still listed, so nothing goes unmentioned — the shell itself ignores the key
entirely, and other platforms ignore it too.
## Client entry (`client.js`)
An ES module whose **default export is a factory**. The shell calls it once
@@ -236,7 +362,7 @@ export default function create({ root, moduleApi }) {
| `instanceId` | Unique per tile — two tiles of the same module get different ids. Handy for keying anything per-tile. |
| `variant` | For multi-tile modules: the id of the tile-list entry this tile was added as, `''` otherwise (see **Presenting multiple tiles**). |
| `config` | Admin (server-wide) config, read-only, always current. Password fields are absent — they never reach the client. |
| `instanceSettings` | This tile's settings: your `instanceSchema` defaults overlaid with whatever this tile has saved. Read it fresh whenever you render — don't cache it. |
| `instanceSettings` | This tile's settings: your `instanceSchema` defaults overlaid with whatever this tile has saved. Read it fresh whenever you render — don't cache it. Keys beginning `__` are the shell's own (`__tint`, the tile's colour) — never use that prefix, and ignore the ones you find. |
| `saveInstanceSettings(patch)` | Merge `patch` into this tile's settings and persist them with the layout. No restart happens — you made the change, you already know. Use it for state the user sets *inside* your tile (a toggled filter, a chosen tab). |
| `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules/<id>/state`. |
| `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. |
@@ -251,16 +377,17 @@ bar, next to the shell's gear/close buttons:
```js
const btn = moduleApi.header.addButton({
icon: '<svg …>…</svg>', // inline SVG string, and/or
label: 'A+', // short text
title: 'Larger text', // hover tooltip
icon: '<svg …>…</svg>', // inline SVG string (13 px), and/or
label: 'Timers ▾', // short text
title: 'Larger text', // tooltip — one or two words; the shell shows
// it the moment the pointer arrives
onClick() { … },
});
btn.classList.add('active'); // toggle styling for on/off buttons
const menu = moduleApi.header.addMenu({
label: 'Channels ▾',
title: 'Choose visible channels',
title: 'Channels',
build(menuEl) {
// called on every open with an emptied menu element — fill it with
// buttons (give them your scoped classes; the container is styled and
@@ -271,12 +398,25 @@ menu.setLabel('Channels (2/4) ▾'); // update the button text any time
```
Everything you add is removed automatically when your instance stops, so a
remount never duplicates controls. Keep them compact (icon buttons, short
labels): the title bar is 32 px tall and shared with the tile's name.
remount never duplicates controls. **Icons only, no labels** (rule 9): the
title bar is 32 px tall and shared with the tile's name, and the tooltip
says what the icon means. Draw icons as inline SVG rather than characters —
a glyph such as ✕ or + takes its size from whatever font each browser
picks; text-size buttons are a drawn − and +. A small state indicator may
ride alongside an icon (ProdCom's channel button carries a count while a
filter is on); a name may not.
One more thing to know: the user can hide the whole title bar with the
small notch on its bottom edge (a clean-view mode for wall displays). Your
controls disappear with it, so nothing essential — status, reconnects,
**One control per action** (rule 11). Whatever has a dedicated header button
is not offered again in the gear popover: text size is − / + up here
(persisted through `saveInstanceSettings({ textSize })`, read back with a
sane fallback), never a "Text size" field in `instanceSchema`; a timestamps
or newest-first toggle in the header means no such switch in the gear. The
gear is for display choices that have no button of their own.
One more thing to know: the whole title bar folds away when the dashboard
auto-hides it, and always in fullscreen (a clean-view mode for wall
displays) — it comes back only while the pointer is at the tile's top edge.
Your controls fold away with it, so nothing essential — status, reconnects,
live data — may exist *only* as a header control.
`sse(path, handlers)` handlers:
@@ -291,24 +431,33 @@ const stream = moduleApi.sse('/stream', {
// later: stream.close()
```
Browsers' `EventSource` retries transient drops itself but gives up for good
when a retry gets a completed non-SSE response — which is what a proxy's 502
looks like while the upstream is down. The shell's `sse()` recreates closed
streams on a 3-second timer until they work again, so **reconnect logic is
free**: handle `open` (refresh/backfill your state; you may have missed
events) and `error` (report degraded status), and you're resilient. Streams
you forget to close are force-closed when the tile unmounts — but close your
own in `stop()` anyway.
Your route serves a plain SSE stream; how it reaches the page is the
shell's business. A dashboard holds **one** connection for every stream on
it (`GET /api/stream`, which the server feeds from your route over
loopback) — browsers allow about six connections to a host and a tile per
stream ran out of them — so `open` also fires whenever that shared
connection is remade (a tile was added or removed) and whenever your route
comes back after a drop. Browsers' `EventSource` retries transient drops
itself but gives up for good when a retry gets a completed non-SSE
response — which is what a proxy's 502 looks like while the upstream is
down; the shell recreates a closed connection on a 3-second timer until it
works again, so **reconnect logic is free**: handle `open` (refresh/backfill
your state; you may have missed events) and `error` (report degraded
status), and you're resilient. Streams you forget to close are force-closed
when the tile unmounts — but close your own in `stop()` anyway.
### Lifecycle you must expect
- **Any number of instances.** Two transcript tiles with different filters
is a feature. Keep all state inside `create()`'s closure — no module-level
mutable state in `client.js`.
- **Remounts are routine.** When the user edits the tile's gear settings the
shell calls `stop()` and starts a fresh instance. When admin config
changes, the shell calls your `onConfigChange(cfg)` if you export one,
otherwise it remounts you. Either way you must come back cleanly.
- **Remounts are routine.** Gear settings apply as the user changes them —
there is no Apply button — so each change calls `stop()` and starts a fresh
instance (typing is debounced, and an unchanged value remounts nothing).
Starting cheaply matters: hold connections and timers in the closure and
tear them down in `stop()`. When admin config changes, the shell calls your
`onConfigChange(cfg)` if you export one, otherwise it remounts you. Either
way you must come back cleanly.
- **The tile can be removed at any moment.** `stop()` is your only notice.
### Rules for the client
@@ -324,9 +473,15 @@ own in `stop()` anyway.
2. **Style with the shell's CSS variables — no hardcoded colors.** The
palette: `--bg` (page), `--panel` (tile), `--border`, `--text`,
`--muted`, `--accent`, `--danger`, `--warn`. Derive tints with
`--muted`, `--accent`, `--accent-text` (text that sits on the accent —
dark on most themes, white where the accent is deep), `--danger`,
`--warn`. Derive tints with
`color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes
every module look native on the dark, high-contrast booth theme.
every module look native — and what makes the dashboard's **themes**
(Admin → Theme) reach into every tile: a theme swaps those variables and
nothing else. A colour a tile is configured with (a `color` instance
setting, applied as a custom property on the tile) is set closer than the
theme and wins over it, by design.
3. **No external network calls from the client.** Browsers talk only to
ProdDash. Anything upstream (your device, service, API) goes through your
@@ -346,7 +501,7 @@ ethos; Node 18+ so global `fetch` is available). Exports either or both of:
```js
module.exports = {
init({ config, log }) {
init({ config, log, shell }) {
// Called on startup / re-init, BEFORE routes(). Start your upstream
// connection here. Return a handle:
return {
@@ -355,7 +510,7 @@ module.exports = {
};
},
routes({ config, log }) {
routes({ config, log, shell }) {
// Route table, mounted under /api/modules/<id>/
return {
'GET /state': (req, res) => { … },
@@ -371,6 +526,9 @@ module.exports = {
- `config` is your admin config: `configSchema` defaults overlaid with what
the admin page saved — **including password fields**.
- `log(...)` prefixes output with your module id.
- `shell.port` is the port ProdDash is serving on — what you need to read
another module's routes over loopback (`http://127.0.0.1:${shell.port}/api/
modules/<id>/…`). Never guess it.
- Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is
the matched remainder (leading `/` included) and `req.search` is the query
string (with `?`, or `''`) — so a proxy rebuilds the upstream path as
@@ -407,7 +565,7 @@ module.exports = {
return config.pages.map((page) => ({
id: page.name, // stable id, stored with the tile
name: page.name, // picker label AND the tile's title
description: page.url, // optional line under the label
description: page.url, // optional; the picker's tooltip
settings: { page: page.name }, // preset instance settings
defaultSize: { w: 4, h: 3 }, // optional per-entry size overrides
minSize: { w: 2, h: 2 },
@@ -420,13 +578,31 @@ module.exports = {
keep it fast (answer from state you already hold; don't fetch upstream on
demand). A throw or a hang (>2 s) falls back to the classic single entry,
so a dead upstream can't break the picker.
- `instanceSchema` / `instanceGroups` on an entry replace the module's for
tiles added from it — settings that fit that tile, nothing else.
- `aliases: ['old-id', …]` on an entry names earlier variant ids it stands
for: a tile added under one of them (a layout saved by an older version of
your module) gets this entry's settings too. A tile whose variant matches
no entry and no alias falls back to the module's full `instanceSchema`.
- Entry `description`s are tooltips in the + picker now, not visible lines —
one short sentence.
- `single: true` on an entry allows at most one such tile per dashboard: the
picker greys the entry out once it is placed. Use it for a control surface
that would only confuse in duplicate — a send box, a master switch.
- When the user adds an entry, its `settings` overlay your `instanceSchema`
defaults in that tile's instance settings, the entry's `name` becomes the
tile's title, and `moduleApi.variant` carries its `id`.
- The tile keeps its variant id and title even if the entry later vanishes
from your list (a timer deleted in ProPresenter, a page removed in admin).
**Your client decides what that means** — show a clear "no longer
configured" state rather than erroring.
- **A tile is shown only while its entry is offered.** The shell mounts a
tile added from an entry only when that entry (or one of its `aliases`) is
in your list right now; otherwise the tile keeps its place in the layout,
draws nothing, and appears the moment your list offers it again — a
Service timer waits for PCO Plan, a transcript for its channel. So your
client is never asked to render an entry you did not offer at mount time.
One case is still yours: an entry that vanishes *after* the tile mounted
(a timer deleted in ProPresenter mid-service). Render nothing for it — no
placeholder, no error — and the shell folds the tile away at its next
registry refresh. Never fall back to your default view in its place: five
different timers turning into five "All timers" is worse than five gaps.
- A client-only module can declare a fixed list as a `tiles` array in
`module.json` instead (same entry shape).
- An empty list (or no `tiles` at all) keeps today's behavior: one picker
@@ -457,6 +633,71 @@ per-client `res.write` in try/catch (drop the client on failure). The
`propresenter-now-next` module is the reference implementation of this
pattern.
## Working with other modules
Modules don't import each other. When one needs what another knows, it reads
that module's **own server routes over loopback**, exactly as a browser would:
`http://127.0.0.1:${shell.port}/api/modules/<id>/…`. The PCO Plan module
follows ProPresenter this way — it reads `propresenter-now-next`'s `/stream`
rather than talking to ProPresenter a second time. A consumer must survive the
provider being disabled, uninstalled, or restarting mid-service (404s and
dropped connections are normal; retry with backoff and say so in `health()`).
### Offering timers to the Timers module
The Timers module shows timers from anywhere: ProPresenter, LTC, its own
clock, and any module that offers some. A module offers timers by:
1. Declaring it — `"provides": ["timers"]` in `module.json`. The shell
forwards `provides` in `/api/modules`, which is how the Timers module finds
providers without a list anyone maintains.
2. Serving `GET /timers` from its server routes, returning a snapshot:
```json
{
"source": "pco-plan",
"label": "PCO Plan",
"updatedAt": 1757800000000,
"timers": [
{
"id": "service",
"label": "Service",
"kind": "elapsed",
"state": "running",
"startedAt": 1757799000000,
"elapsedMs": 1000000,
"remainingMs": null,
"targetMs": 4500000,
"status": { "text": "Welcome & Announcements", "tone": "ok" },
"detail": "Item 3 of 12"
}
]
}
```
| Field | Meaning |
| --- | --- |
| `source`, `label` | Your module id, and the heading the Timers module puts over your timers. |
| `updatedAt` | ms since epoch when this snapshot was taken. Every duration below is *as of this instant*. |
| `id` | Stable within your module — the Timers module keys tile settings (shown/hidden, colours) on `source` + `id`. |
| `kind` | `elapsed` (counts up), `countdown` (counts down to zero), `clock` (a time of day; put the ISO time in `status.text`). |
| `state` | `running`, `paused`, `stopped` (finished; value frozen), `idle` (exists, hasn't started — a timer that will start at 11:00). |
| `startedAt` | ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll. |
| `elapsedMs`, `remainingMs` | The values at `updatedAt`; `null` when not meaningful for the kind. |
| `targetMs` | Planned length (elapsed) or full length (countdown), or `null`. Lets the consumer colour overruns. |
| `status` | Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". `tone`: `ok`, `warn`, `danger`, `muted`. |
| `detail` | Optional second line. |
The consumer derives the displayed time locally from `startedAt`/`elapsedMs`
and its own clock, so a snapshot every couple of seconds is plenty — `/timers`
must be cheap to answer (serve from state you already hold; never call
upstream to answer it). Optionally also serve `GET /timers/stream` as SSE
(`event: timers`, `data:` the same object, on every change plus a heartbeat
comment every 15 s); the consumer uses the stream when it exists and polls
`/timers` otherwise. Only offer timers a production tech would want on a
wall — the Timers module lets them hide any of them per tile, but every entry
you add is a decision someone has to make.
## Complete minimal example
A working clock — copy-paste this as your starter. (A slightly fancier
+180
View File
@@ -0,0 +1,180 @@
# ProdDash for macOS
The Mac application: a menu-bar app that carries the dashboard, its own Node
runtime, and the permissions its modules need. Installing it is dragging
**ProdDash** to Applications — nothing else has to be present on the machine,
and nothing can go missing from under it.
Build it with `./build.sh`. The app is native Swift with no dependencies of
its own; the Node it ships is fetched from nodejs.org at build time and
checksum-verified.
## What's in the bundle
```
ProdDash.app/Contents/
MacOS/ProdDash the launcher (Swift, ~1 MB)
Resources/node/bin/node the Node runtime ProdDash runs on
Resources/app/ server.js, public/, modules/ — ProdDash itself
```
Everything mutable lives outside, in `~/Library/Application Support/ProdDash`:
module config, layouts, modules installed from the admin page — and `app/`,
the copy of ProdDash that actually runs.
**Why a copy, when the bundle already has one.** ProdDash updates itself: the
admin page's **Update now** downloads a new version and writes it over the app
folder. Nothing may write inside a signed `.app` without breaking its
signature, so the bundle carries a read-only baseline and the data directory
carries the copy that runs. Both routes to a new version work, and they don't
fight: installing a newer `.app` replaces the copy, while a copy that has
already updated itself past the bundle is left alone.
## Why it exists
**Permissions.** macOS grants privacy permissions to the *application* that
owns a process, and a child inherits its parent's identity. Started from here,
node — and the Timers module's `ltc-capture` — belong to this app bundle, so
the microphone and local-network prompts are asked, and remembered, as
ProdDash. Started over SSH or from a bare launchd job there is nobody to ask:
the permission is silently absent rather than refused, which is the "endless
zeroes, no error" failure `ltc-listener.js` warns about.
**Supervision.** The shell was built for this: with `PRODDASH_LAUNCHER=1` it
exits **75** instead of respawning itself when the admin page applies an
update, which the launcher reads as "start me again" rather than as a fault.
A crash gets a backed-off restart (1s, 2s, 5s, 10s); four in two minutes stops
the cycle rather than spinning forever.
## Build
```bash
./build.sh # build/ProdDash.app — the whole application
./build.sh --dmg # …and build/ProdDash-<version>-arm64.dmg
./build.sh --install # …and copy it to /Applications
./build.sh --run # …and open it
./build.sh --notarize # with --dmg: notarise and staple (Developer ID)
./build.sh --dev # launcher only: no Node, no server — runs against
# the checkout. Seconds, for work on the launcher.
```
Needs the Xcode command line tools (`xcode-select --install`) and macOS 13 or
newer. The first build downloads Node (~50 MB) into `.cache/` and reuses it.
A full build takes about ten seconds and produces a 117 MB app, 43 MB
compressed in the disk image.
Builds for **Apple Silicon**. To change that, or to move to a newer Node, edit
`ARCH` and `NODE_VERSION` at the top of `build.sh` — the download is verified
against nodejs.org's published checksums either way.
The app icon is built from `public/icons/icon-512.png`, so ProdDash's own icon
is the app's icon. A module's native helper is compiled during the build
(`<name>.swift` beside a module's files becomes `<name>`), so the Mac running
ProdDash never needs a compiler.
## Signing
`build.sh` uses a **Developer ID Application** certificate when one is
installed, and signs ad-hoc when none is — which is fine on your own Macs, but
another Mac will refuse the app until someone approves it under System
Settings → Privacy & Security.
To sign properly you need the Apple Developer Program (paid) and a Developer
ID certificate — note this is not the same as the "Apple Development"
certificate Xcode creates for you:
1. Xcode → Settings → Accounts → your team → **Manage Certificates** →
**+** → **Developer ID Application**. (Requires Account Holder or Admin
on the team.) `security find-identity -v -p codesigning` should then
list a `Developer ID Application: …` identity.
2. Store notarisation credentials once, with an app-specific password from
[appleid.apple.com](https://appleid.apple.com):
```bash
xcrun notarytool store-credentials proddash --apple-id you@example.com --team-id TEAMID
```
3. `./build.sh --notarize`
Then the disk image opens by double-click on any Mac, with no warnings.
`CODESIGN_IDENTITY` overrides the certificate; `NOTARY_PROFILE` overrides the
keychain profile name.
## Using it
The menu-bar icon is ProdDash's 2×2 tile mark; the accent tile is lit green
while the server runs, amber mid-move, red when something needs a person, and
hollow when it is stopped. Its menu has the whole app in it:
- **Open Dashboard** / **Open Admin** — the running server, in a browser.
- **Start / Stop / Restart ProdDash**.
- **Launcher…** — the window below.
- **Open at Login** — a login item, through the modern `SMAppService` API.
- **Quit ProdDash** — stops the server too, and asks first while it is
running.
The window has the status, both addresses the dashboard answers on, and three
tabs:
- **Log** — everything the server prints, plus what the launcher did and why.
Also written to `~/Library/Logs/ProdDash/proddash-<date>.log`, kept a week.
- **Settings** — the port, and how it starts (at login, server on open,
restart on crash, open a browser, show this window). It also shows which
copy of ProdDash is running, which node, and where settings are kept.
- **Permissions** — one line per permission: a status dot, its name, and the
one thing you can do about it. See below.
### Port
The port belongs to ProdDash, not to the launcher, so it is written to
`proddash.json` in the data directory — the file the server already reads.
Set it here and ProdDash keeps that port however it is started next. Changing
it while the server runs restarts it.
### Permissions
What appears is what the installed modules declare in their manifests
(`"permissions": [ … ]` — see
[docs/MODULE-GUIDE.md](../../docs/MODULE-GUIDE.md)); hover a row to see which
module asked, and why.
- **Microphone** — asked for directly, with the status macOS reports.
- **Local Network** — macOS has no API that reports whether this was granted,
so **Check Access** asks the network instead: it browses for Bonjour
services (which is what raises the prompt) and tries every upstream the
admin page is pointed at. Nothing answering, when the gear is on, means
macOS is withholding access.
- **Notifications** — the launcher's own, so a server that stops while nobody
is watching still reaches someone. Turn them off for ProdDash in System
Settings if you'd rather not have them.
On first run the launcher asks for what the modules declare, while someone is
still at the keyboard, rather than halfway through a service.
## Details worth knowing
- **One instance.** Opening a second copy raises the first one's window.
- **Port already in use.** If something already holds the port, the launcher
asks before doing anything. When that something answers like ProdDash — an
SSH-started copy, say, with no microphone access — it offers to take the
port over.
- **Stopping is thorough.** The server is spawned into its own process group,
so stopping it also stops anything it started. An orphaned `ltc-capture`
would otherwise hold an audio device until someone found it in Activity
Monitor.
- **Signals.** `killall ProdDash`, a logout or a shutdown stops the server
first rather than leaving node running.
- **Nothing is required outside the app.** A development build (`--dev`)
carries no server and no Node, and runs the checkout it was built in with
whatever node is on the machine — Settings → ProdDash folder points it
somewhere else.
## Troubleshooting
| What you see | What it means |
| --- | --- |
| "ProdDash can't be opened because Apple cannot check it" | The app is ad-hoc signed. System Settings → Privacy & Security → **Open Anyway**, once. Signing it properly (above) removes this. |
| "ProdDash stopped 4 times in two minutes" | The server is failing on startup — the Log tab has its own error. |
| LTC shows no signal | Permissions tab → Microphone. If it says granted and the input is still silent, the server may be an older copy started outside the app — Stop, then Start. |
| Nothing on the network answers | Permissions tab → **Check Access**. |
| A development build says it can't find ProdDash | `--dev` builds need a checkout: Settings → ProdDash folder. |
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>ProdDash</string>
<key>CFBundleDisplayName</key>
<string>ProdDash</string>
<key>CFBundleExecutable</key>
<string>ProdDash</string>
<key>CFBundleIdentifier</key>
<string>org.waterschurch.proddash</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<key>ProdDashLauncherVersion</key>
<string>1.0.0</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<!-- Menu-bar app: no Dock icon, no app menu. -->
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<!-- The prompts this app exists to own. macOS shows these sentences
verbatim, so they say what ProdDash does with each permission. -->
<key>NSMicrophoneUsageDescription</key>
<string>ProdDash reads an audio input on this Mac to decode SMPTE LTC timecode for the Timers module.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>ProdDash connects to production gear on this network — ProdCom, ProPresenter and anything else its modules are pointed at.</string>
<key>NSBonjourServices</key>
<array>
<string>_http._tcp</string>
</array>
</dict>
</plist>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The launcher asks for microphone access on the modules' behalf; under
the hardened runtime that needs saying out loud. -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Module helpers that read an audio input — ltc-capture, and anything
like it a later module ships. -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- V8 compiles JavaScript as it runs; without these the hardened runtime
kills node on its first optimisation. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- A module may ship a native addon that nobody signed with our identity. -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+335
View File
@@ -0,0 +1,335 @@
import AppKit
import Combine
/**
The launcher itself: one status-bar item, one window, one server.
It is an accessory app (no Dock icon, no app menu), so everything it can do
is reachable from the menu-bar menu — including quitting, which stops the
server with it.
*/
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
/// The launcher's own version. The app's CFBundleShortVersionString is
/// ProdDash's, because the app *is* ProdDash now.
static var version: String {
Bundle.main.infoDictionary?["ProdDashLauncherVersion"] as? String ?? "dev"
}
/// Sent by a second copy of the launcher so the first one shows itself.
static let showNotification = Notification.Name("org.waterschurch.proddash.launcher.show")
let settings = LauncherSettings()
let permissions = PermissionsModel()
lazy var server = ServerController(settings: settings)
private var statusItem: NSStatusItem?
private var windowController: LauncherWindowController?
private var cancellables = Set<AnyCancellable>()
private var appearanceObserver: NSKeyValueObservation?
private var signalSources: [DispatchSourceSignal] = []
// MARK: - Lifecycle
func applicationDidFinishLaunching(_ notification: Notification) {
guard !handOffToRunningInstance() else { return }
LogStore.shared.launcher("ProdDash Launcher \(Self.version)")
setUpStatusItem()
server.onPortBusy = { [weak self] port, isProdDash, reply in
self?.askAboutBusyPort(port, isProdDash: isProdDash, reply: reply)
}
// Any change in the server's state re-draws the icon; the menu rebuilds
// itself when it opens.
server.objectWillChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in self?.updateStatusItem() }
.store(in: &cancellables)
DistributedNotificationCenter.default().addObserver(
self, selector: #selector(showWindow), name: Self.showNotification, object: nil)
catchShutdownSignals()
permissions.refresh(root: server.rootURL, dataDir: server.dataDir)
updateStatusItem()
let firstRun = !settings.hasRunBefore
settings.hasRunBefore = true
if settings.showWindowAtLaunch || firstRun { showWindow() }
if settings.startServerAtLaunch { server.start() }
// Owning the permissions means asking for them at a sensible moment —
// now, with someone at the keyboard — rather than halfway through a
// service when a module first reaches for the microphone.
if firstRun { requestDeclaredPermissions() }
}
/// Something else is quitting us — a logout, a restart, `osascript … quit`.
/// Stop the server first, but never let that wedge a logout: the backstop
/// leaves anyway if the reply hasn't gone out in time.
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
guard server.state.isBusy else { return .terminateNow }
LogStore.shared.launcher("quitting — stopping ProdDash first")
server.stop()
replyWhenStopped(within: 36)
DispatchQueue.global().asyncAfter(deadline: .now() + 12) { exit(0) }
return .terminateLater
}
/// Waiting here with a Timer would hang the quit outright: while the app
/// sits on `.terminateLater` the run loop is not in its default mode, so a
/// scheduled timer never fires.
private func replyWhenStopped(within ticks: Int) {
guard server.state.isBusy, ticks > 0 else {
return NSApp.reply(toApplicationShouldTerminate: true)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.replyWhenStopped(within: ticks - 1)
}
}
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { true }
/// A Cocoa app doesn't run its normal quit path when something sends it a
/// signal — `killall`, a logout, a stop from a terminal. Without this the
/// launcher would vanish and leave node (and its audio capture) running.
private func catchShutdownSignals() {
for number in [SIGTERM, SIGINT, SIGHUP] {
signal(number, SIG_IGN)
let source = DispatchSource.makeSignalSource(signal: number, queue: .main)
source.setEventHandler { [weak self] in
LogStore.shared.launcher("signal \(number) — stopping ProdDash and quitting")
self?.server.stop()
self?.exitOnceStopped(within: 32)
}
source.resume()
signalSources.append(source)
}
}
private func exitOnceStopped(within ticks: Int) {
guard server.state.isBusy, ticks > 0 else { exit(0) }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.exitOnceStopped(within: ticks - 1)
}
}
/// A second launch just shows the copy that is already running.
private func handOffToRunningInstance() -> Bool {
guard let id = Bundle.main.bundleIdentifier else { return false }
let mine = ProcessInfo.processInfo.processIdentifier
let others = NSRunningApplication.runningApplications(withBundleIdentifier: id)
.filter { $0.processIdentifier != mine }
guard !others.isEmpty else { return false }
DistributedNotificationCenter.default().post(name: Self.showNotification, object: nil)
NSApp.terminate(nil)
return true
}
// MARK: - Status item
private func setUpStatusItem() {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
let menu = NSMenu()
menu.delegate = self
menu.autoenablesItems = false
item.menu = menu
statusItem = item
// The glyph is drawn, not templated, so it has to be redrawn when the
// menu bar switches between light and dark.
appearanceObserver = item.button?.observe(\.effectiveAppearance) { [weak self] _, _ in
DispatchQueue.main.async { self?.updateStatusItem() }
}
}
private func updateStatusItem() {
guard let button = statusItem?.button else { return }
button.image = StatusIcon.image(for: server.state, healthy: server.healthy)
button.toolTip = "ProdDash — \(server.state.label)"
}
// MARK: - Menu
func menuNeedsUpdate(_ menu: NSMenu) {
menu.removeAllItems()
let version = server.shellVersion.isEmpty ? "" : " \(server.shellVersion)"
menu.addItem(disabled("ProdDash\(version) — \(server.state.label)"))
switch server.state {
case .running:
menu.addItem(disabled("localhost:\(server.port)"))
case .failed(let why):
menu.addItem(disabled(why.prefix(60) + (why.count > 60 ? "…" : "")))
default:
if !server.message.isEmpty { menu.addItem(disabled(String(server.message.prefix(60)))) }
}
menu.addItem(.separator())
let dashboard = item("Open Dashboard", #selector(openDashboard), key: "d")
dashboard.isEnabled = server.state == .running
menu.addItem(dashboard)
let admin = item("Open Admin", #selector(openAdmin), key: "a")
admin.isEnabled = server.state == .running
menu.addItem(admin)
menu.addItem(.separator())
switch server.state {
case .running, .starting:
menu.addItem(item("Stop ProdDash", #selector(toggleServer)))
case .stopping:
menu.addItem(disabled("Stopping…"))
case .stopped, .failed:
menu.addItem(item("Start ProdDash", #selector(toggleServer)))
}
let restart = item("Restart ProdDash", #selector(restartServer))
restart.isEnabled = server.state == .running || server.state == .starting
menu.addItem(restart)
menu.addItem(.separator())
menu.addItem(item("Launcher…", #selector(showWindow), key: ","))
let login = item("Open at Login", #selector(toggleLoginItem))
login.state = LoginItem.isEnabled ? .on : .off
menu.addItem(login)
menu.addItem(.separator())
menu.addItem(item("Quit ProdDash", #selector(quit), key: "q"))
}
private func item(_ title: String, _ action: Selector, key: String = "") -> NSMenuItem {
let item = NSMenuItem(title: title, action: action, keyEquivalent: key)
item.target = self
item.isEnabled = true
return item
}
private func disabled(_ title: any StringProtocol) -> NSMenuItem {
let item = NSMenuItem(title: String(title), action: nil, keyEquivalent: "")
item.isEnabled = false
return item
}
// MARK: - Actions
@objc private func openDashboard() { server.openDashboard() }
@objc private func openAdmin() { server.openAdmin() }
@objc private func toggleServer() { server.toggle() }
@objc private func restartServer() { server.restart() }
@objc func showWindow() {
if windowController == nil {
windowController = LauncherWindowController(delegate: self)
}
NSApp.activate(ignoringOtherApps: true)
windowController?.showWindow(nil)
windowController?.window?.makeKeyAndOrderFront(nil)
}
/// Close the window but stay in the menu bar.
func hideWindow() {
windowController?.window?.orderOut(nil)
}
@objc private func toggleLoginItem() {
let turningOn = !LoginItem.isEnabled
if let error = LoginItem.set(turningOn) {
present(alert: "Couldn't change the login item", detail: error)
return
}
LogStore.shared.launcher("open at login: \(turningOn ? "on" : "off")")
if turningOn && LoginItem.needsApproval {
present(alert: "Approve ProdDash in Login Items",
detail: "macOS needs you to allow ProdDash under System Settings → General → Login Items.",
openSettings: true)
}
}
/// A modal run started while the menu is still closing doesn't reliably
/// appear — let the menu finish first, then ask.
@objc func quit() {
DispatchQueue.main.async { [weak self] in self?.confirmThenQuit() }
}
private func confirmThenQuit() {
// A booth machine shouldn't lose its dashboard to a stray click.
if server.state == .running || server.state == .starting {
let alert = NSAlert()
alert.messageText = "Quit ProdDash?"
alert.informativeText = "The server stops with the launcher, and every dashboard on the network goes dark."
alert.addButton(withTitle: "Quit")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
NSApp.activate(ignoringOtherApps: true)
guard alert.runModal() == .alertFirstButtonReturn else { return }
}
// Deliberately not NSApp.terminate: begun here, while the alert's modal
// session is still unwinding, it leaves AppKit wedged with the main
// queue unserviced — the server stops and the app never finishes
// quitting. Stopping is our own job anyway, so do it and leave.
DispatchQueue.main.async { [weak self] in
guard let self else { exit(0) }
LogStore.shared.launcher("quitting — stopping ProdDash first")
self.server.stop()
self.exitOnceStopped(within: 36)
}
}
// MARK: - Prompts
/// Something already holds the port. If it answers like ProdDash, offer the
/// same hand-over Start ProdDash.command does — that other copy was very
/// likely started over SSH and has no microphone access.
private func askAboutBusyPort(_ port: Int, isProdDash: Bool, reply: @escaping (Bool) -> Void) {
let alert = NSAlert()
if isProdDash {
alert.messageText = "ProdDash is already running on port \(port)"
alert.informativeText = "It was started outside this launcher — over SSH or from Start ProdDash.command "
+ "— so it has no microphone access and this app can't watch over it. Take it over?"
alert.addButton(withTitle: "Take Over")
} else {
alert.messageText = "Port \(port) is already in use"
alert.informativeText = "Another app is listening on port \(port). Stop that app, or give ProdDash a "
+ "different port under Settings. Stopping whatever holds it anyway is rarely what you want."
alert.addButton(withTitle: "Stop It Anyway")
}
alert.addButton(withTitle: "Cancel")
alert.alertStyle = isProdDash ? .informational : .warning
NSApp.activate(ignoringOtherApps: true)
reply(alert.runModal() == .alertFirstButtonReturn)
}
/// Ask for what the installed modules actually declare they need.
func requestDeclaredPermissions() {
permissions.refresh(root: server.rootURL, dataDir: server.dataDir)
for need in permissions.needs {
switch need.kind {
case .microphone:
if permissions.microphone == .notDetermined { permissions.requestMicrophone() }
case .localNetwork:
permissions.checkLocalNetwork(root: server.rootURL, dataDir: server.dataDir)
case .other:
break
}
}
}
/// Deferred for the same reason the quit confirmation is: a modal run
/// started while the menu is still closing doesn't reliably appear.
private func present(alert title: String, detail: String, openSettings: Bool = false) {
DispatchQueue.main.async { [weak self] in
self?.runAlert(title: title, detail: detail, openSettings: openSettings)
}
}
private func runAlert(title: String, detail: String, openSettings: Bool) {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = detail
alert.addButton(withTitle: openSettings ? "Open System Settings" : "OK")
if openSettings { alert.addButton(withTitle: "Later") }
NSApp.activate(ignoringOtherApps: true)
if alert.runModal() == .alertFirstButtonReturn && openSettings {
LoginItem.openSystemSettings()
}
}
}
+185
View File
@@ -0,0 +1,185 @@
import Foundation
/**
The ProdDash server process, spawned into its OWN process group.
Why not Foundation's `Process`: the server starts children of its own — the
Timers module runs `ltc-capture`, which holds an audio input device open. If
the launcher force-kills only node, that capture tool is orphaned and keeps
the device (and the microphone indicator) until someone hunts it down in
Activity Monitor. `posix_spawn` with POSIX_SPAWN_SETPGROUP makes the server a
process-group leader, so `kill(-pid, …)` reaches the whole tree.
Output arrives line by line on the main queue; `onExit` fires once, also on
main, with the exit code and whether a signal killed it.
*/
final class ChildProcess {
/// A spawn that never got off the ground, with something an operator can act on.
struct SpawnError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
let pid: pid_t
/// What we ran, for the log.
let commandLine: String
/// Held for the life of the child: a FileHandle's readability handler does
/// not retain it, so letting these go closes the pipes and the output
/// silently stops.
private let outPipe: Pipe
private let errPipe: Pipe
private var exitSource: DispatchSourceProcess?
private var terminated = false
private init(pid: pid_t, commandLine: String, outPipe: Pipe, errPipe: Pipe) {
self.pid = pid
self.commandLine = commandLine
self.outPipe = outPipe
self.errPipe = errPipe
}
deinit { releasePipes() }
// MARK: - Spawning
static func run(executable: String,
arguments: [String],
directory: String,
environment: [String: String],
onLine: @escaping (String, Bool) -> Void,
onExit: @escaping (Int32, Bool) -> Void) throws -> ChildProcess {
let outPipe = Pipe()
let errPipe = Pipe()
var actions: posix_spawn_file_actions_t?
posix_spawn_file_actions_init(&actions)
defer { posix_spawn_file_actions_destroy(&actions) }
// The server never reads stdin; /dev/null keeps a stray read from blocking it.
posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0)
posix_spawn_file_actions_adddup2(&actions, outPipe.fileHandleForWriting.fileDescriptor, 1)
posix_spawn_file_actions_adddup2(&actions, errPipe.fileHandleForWriting.fileDescriptor, 2)
posix_spawn_file_actions_addchdir_np(&actions, directory)
var attr: posix_spawnattr_t?
posix_spawnattr_init(&attr)
defer { posix_spawnattr_destroy(&attr) }
// SETPGROUP + pgroup 0: the child leads its own group (see the note above).
// CLOEXEC_DEFAULT: nothing but the three descriptors above is inherited.
posix_spawnattr_setflags(&attr, Int16(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_CLOEXEC_DEFAULT))
posix_spawnattr_setpgroup(&attr, 0)
let argv: [UnsafeMutablePointer<CChar>?] = ([executable] + arguments).map { strdup($0) } + [nil]
let envp: [UnsafeMutablePointer<CChar>?] = environment
.map { strdup("\($0.key)=\($0.value)") } + [nil]
defer {
for p in argv where p != nil { free(p) }
for p in envp where p != nil { free(p) }
}
var pid: pid_t = 0
let rc = posix_spawn(&pid, executable, &actions, &attr, argv, envp)
guard rc == 0 else {
outPipe.closeBothEnds()
errPipe.closeBothEnds()
throw SpawnError(message: "could not start \(executable): \(String(cString: strerror(rc))) (\(rc))")
}
let child = ChildProcess(pid: pid,
commandLine: ([executable] + arguments).joined(separator: " "),
outPipe: outPipe, errPipe: errPipe)
// Our copies of the write ends must go, or the reads never see EOF.
try? outPipe.fileHandleForWriting.close()
try? errPipe.fileHandleForWriting.close()
child.pump(outPipe.fileHandleForReading, isError: false, onLine: onLine)
child.pump(errPipe.fileHandleForReading, isError: true, onLine: onLine)
let source = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit, queue: .main)
source.setEventHandler { [weak child] in
var status: Int32 = 0
waitpid(pid, &status, 0)
child?.exitSource?.cancel()
child?.exitSource = nil
// Give the last lines a moment to arrive before letting the pipes go.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { child?.releasePipes() }
// Low 7 bits hold the signal that killed it; 0 means a normal exit.
let signal = status & 0x7f
if signal == 0 {
onExit((status >> 8) & 0xff, false)
} else {
onExit(signal, true)
}
}
child.exitSource = source
source.resume()
return child
}
/// Deliver whole lines on the main queue; partial trailing text waits for the rest.
private func pump(_ handle: FileHandle, isError: Bool, onLine: @escaping (String, Bool) -> Void) {
var buffer = Data()
handle.readabilityHandler = { file in
let chunk = file.availableData
if chunk.isEmpty { // EOF — the child closed this end
file.readabilityHandler = nil
if !buffer.isEmpty, let tail = String(data: buffer, encoding: .utf8) {
DispatchQueue.main.async { onLine(tail, isError) }
}
try? file.close()
return
}
buffer.append(chunk)
var lines: [String] = []
while let nl = buffer.firstIndex(of: 0x0a) {
let raw = buffer.subdata(in: buffer.startIndex..<nl)
buffer.removeSubrange(buffer.startIndex...nl)
lines.append(String(data: raw, encoding: .utf8) ?? "")
}
if lines.isEmpty { return }
DispatchQueue.main.async { for line in lines { onLine(line, isError) } }
}
}
private func releasePipes() {
for handle in [outPipe.fileHandleForReading, errPipe.fileHandleForReading] {
handle.readabilityHandler = nil
try? handle.close()
}
}
// MARK: - Stopping
var isAlive: Bool { kill(pid, 0) == 0 }
/// SIGTERM to the whole group. The server unmounts its modules and exits
/// within ~1.5 s; `forceKill()` is the backstop if it doesn't.
func terminate() {
guard !terminated else { return }
terminated = true
signalGroup(SIGTERM)
}
func forceKill() {
signalGroup(SIGKILL)
}
private func signalGroup(_ sig: Int32) {
// Only address the group when the child really leads one — otherwise a
// negative pid could reach processes that are none of our business.
if getpgid(pid) == pid {
kill(-pid, sig)
} else {
kill(pid, sig)
}
}
}
private extension Pipe {
func closeBothEnds() {
try? fileHandleForReading.close()
try? fileHandleForWriting.close()
}
}
+361
View File
@@ -0,0 +1,361 @@
import Foundation
/**
Where things are, and which settings belong to whom.
Two kinds of setting live in this app, and they are deliberately kept apart:
* **ProdDash settings** (the port) belong to ProdDash, so they are written
where ProdDash already looks for them — `proddash.json` in the per-machine
data directory. Start the server any other way (`Start ProdDash.command`,
a terminal) and it still comes up on the port set here.
* **Launcher settings** (auto-start, restart-on-crash, where the checkout
lives) belong to this app and live in its own preferences.
The data-directory and port rules mirror server.js exactly — if that file's
`resolveDataDir` / `PORT` logic changes, change it here too.
*/
enum ProdDash {
// MARK: - Data directory (mirrors server.js resolveDataDir)
/// `PRODDASH_DATA_DIR` if the environment sets one (relative paths resolve
/// against the checkout, as in server.js), else the per-machine default.
static func dataDir(root: URL?) -> URL {
let fromEnv = (ProcessInfo.processInfo.environment["PRODDASH_DATA_DIR"] ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if !fromEnv.isEmpty {
if fromEnv.hasPrefix("/") { return URL(fileURLWithPath: fromEnv) }
if let root { return root.appendingPathComponent(fromEnv).standardizedFileURL }
}
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/ProdDash")
}
// MARK: - What the app carries
/**
A shipped ProdDash lives read-only inside the app bundle and runs from a
copy in the data directory.
The copy isn't ceremony: ProdDash updates itself in place — the admin
page's "Update now" writes a fresh download over the app folder — and
nothing may write inside a signed .app without breaking its signature.
So the bundle carries the baseline, the data directory carries the copy
that actually runs, and both routes to a new version keep working: drop
in a newer .app, or press Update now.
A development build ships no payload and falls through to the checkout it
was built in.
*/
static func bundledPayload() -> URL? {
guard let resources = Bundle.main.resourceURL else { return nil }
let payload = resources.appendingPathComponent("app")
return isRoot(payload) ? payload : nil
}
/// The Node the app carries, so nothing depends on what this Mac happens
/// to have installed — or on it staying installed.
static func bundledNode() -> URL? {
guard let resources = Bundle.main.resourceURL else { return nil }
let node = resources.appendingPathComponent("node/bin/node")
return FileManager.default.isExecutableFile(atPath: node.path) ? node : nil
}
static func workingCopy(dataDir: URL) -> URL { dataDir.appendingPathComponent("app") }
/**
Put the bundled ProdDash in place: when there is no copy yet, or when the
app carries a newer one than the copy — someone installed a new .app. A
copy that has updated itself *past* the bundle is left alone, so a new
install never drags a running system backwards.
*/
@discardableResult
static func seedWorkingCopy(dataDir: URL, log: (String) -> Void) -> URL? {
guard let payload = bundledPayload() else { return nil }
let working = workingCopy(dataDir: dataDir)
let shipped = shellVersion(root: payload)
if isRoot(working) {
let installed = shellVersion(root: working)
guard compareVersions(shipped, installed) > 0 else { return working }
log("this app carries ProdDash \(shipped) — replacing the \(installed) copy it runs")
} else {
log("first run — installing ProdDash \(shipped)")
}
let fm = FileManager.default
let previous = working.appendingPathExtension("previous")
do {
try fm.createDirectory(at: dataDir, withIntermediateDirectories: true)
try? fm.removeItem(at: previous)
if fm.fileExists(atPath: working.path) { try fm.moveItem(at: working, to: previous) }
try fm.copyItem(at: payload, to: working)
makeWritable(working)
try? fm.removeItem(at: previous)
return working
} catch {
log("could not install the bundled ProdDash: \(error.localizedDescription)")
// Put back whatever was working before rather than leaving nothing.
if !fm.fileExists(atPath: working.path), fm.fileExists(atPath: previous.path) {
try? fm.moveItem(at: previous, to: working)
}
return isRoot(working) ? working : nil
}
}
/**
Go back to the copy ProdDash set aside before its last update.
A version that will not stay up is the one state this app cannot talk its
way out of: the admin page went down with it, and installing a fresh
`.app` does nothing, because seeding only replaces an *older* copy and a
broken one carries a higher version. So ProdDash writes `app.previous`
before it overwrites itself and drops it again once the new version has
stayed up — meaning that folder exists only while an update is still
unproven. This puts it back.
Returns the version restored, or nil when there is nothing to go back to.
*/
@discardableResult
static func restorePrevious(root: URL, log: (String) -> Void) -> String? {
let fm = FileManager.default
let previous = URL(fileURLWithPath: root.path + ".previous")
guard isRoot(previous) else { return nil }
let version = shellVersion(root: previous)
let named = version.isEmpty ? "(unknown version)" : version
let broken = URL(fileURLWithPath: root.path + ".broken")
do {
try? fm.removeItem(at: broken)
if fm.fileExists(atPath: root.path) { try fm.moveItem(at: root, to: broken) }
try fm.moveItem(at: previous, to: root)
makeWritable(root)
try? fm.removeItem(at: broken)
log("rolled ProdDash back to \(named) — the copy it replaced")
return named
} catch {
log("could not roll back to the previous copy: \(error.localizedDescription)")
// Leave something runnable behind rather than nothing at all.
if !fm.fileExists(atPath: root.path), fm.fileExists(atPath: broken.path) {
try? fm.moveItem(at: broken, to: root)
}
return nil
}
}
/// Files copied out of a bundle can arrive read-only, and this copy is
/// exactly where ProdDash writes its own updates.
private static func makeWritable(_ root: URL) {
let fm = FileManager.default
var targets = [root]
if let walker = fm.enumerator(at: root, includingPropertiesForKeys: nil) {
for case let url as URL in walker { targets.append(url) }
}
for url in targets {
guard let mode = (try? fm.attributesOfItem(atPath: url.path))?[.posixPermissions] as? NSNumber
else { continue }
let writable = mode.uint16Value | 0o200
if writable != mode.uint16Value {
try? fm.setAttributes([.posixPermissions: NSNumber(value: writable)], ofItemAtPath: url.path)
}
}
}
// MARK: - Finding the checkout
/// A folder is ProdDash if it has the two files the server cannot run without.
static func isRoot(_ url: URL) -> Bool {
let fm = FileManager.default
return fm.fileExists(atPath: url.appendingPathComponent("server.js").path)
&& fm.fileExists(atPath: url.appendingPathComponent("public/index.html").path)
}
/**
Find the ProdDash folder: the one the operator chose, else the checkout
this .app was built inside (the app usually sits in `launcher/macos/build`),
else the handful of places people actually keep it.
*/
static func findRoot(preferred: String?, dataDir: URL) -> URL? {
if let preferred, !preferred.isEmpty {
let url = URL(fileURLWithPath: preferred).standardizedFileURL
if isRoot(url) { return url }
}
if let env = ProcessInfo.processInfo.environment["PRODDASH_ROOT"], !env.isEmpty {
let url = URL(fileURLWithPath: env).standardizedFileURL
if isRoot(url) { return url }
}
// A shipped app runs the copy it installed; only a development build
// (no payload) goes looking for a checkout.
if bundledPayload() != nil {
let working = workingCopy(dataDir: dataDir)
if isRoot(working) { return working }
}
var up = Bundle.main.bundleURL.standardizedFileURL
for _ in 0..<6 {
up.deleteLastPathComponent()
if up.path == "/" { break }
if isRoot(up) { return up }
}
let home = FileManager.default.homeDirectoryForCurrentUser
var candidates = [
home.appendingPathComponent("Apps/ProdDash"),
home.appendingPathComponent("ProdDash"),
home.appendingPathComponent("Documents/ProdDash"),
URL(fileURLWithPath: "/Applications/ProdDash"),
URL(fileURLWithPath: "/Users/Shared/ProdDash"),
]
// …and next to the app itself, for a "ProdDash Launcher.app beside the folder" layout.
candidates.append(Bundle.main.bundleURL.deletingLastPathComponent()
.appendingPathComponent("ProdDash"))
return candidates.first(where: isRoot)?.standardizedFileURL
}
/// The shell's version, straight from the checkout's package.json.
static func shellVersion(root: URL) -> String {
guard let data = try? Data(contentsOf: root.appendingPathComponent("package.json")),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let version = json["version"] as? String else { return "" }
return version
}
// MARK: - Finding node
/// Everywhere a Mac plausibly keeps node, in the order worth trying.
static func findNode(root: URL?, preferred: String?) -> URL? {
var tries: [URL] = []
if let preferred, !preferred.isEmpty { tries.append(URL(fileURLWithPath: preferred)) }
// What the app carries, so a shipped ProdDash needs nothing installed.
if let bundled = bundledNode() { tries.append(bundled) }
// The runtime a production machine ships with — same preference as Start ProdDash.command.
if let root { tries.append(root.appendingPathComponent("runtime/bin/node")) }
tries += ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/opt/local/bin/node", "/usr/bin/node"]
.map { URL(fileURLWithPath: $0) }
let home = FileManager.default.homeDirectoryForCurrentUser
tries += newestNodeUnder(home.appendingPathComponent(".nvm/versions/node"))
tries += [home.appendingPathComponent(".volta/bin/node")]
if let found = tries.first(where: isExecutable) { return found }
// Last resort: ask a login shell, which is where a fancier version
// manager (asdf, fnm, mise) puts node on the PATH.
if let fromShell = loginShellNode(), isExecutable(fromShell) { return fromShell }
return nil
}
private static func isExecutable(_ url: URL) -> Bool {
FileManager.default.isExecutableFile(atPath: url.path)
}
private static func newestNodeUnder(_ dir: URL) -> [URL] {
guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else { return [] }
return names.sorted { compareVersions($0, $1) > 0 }
.map { dir.appendingPathComponent($0).appendingPathComponent("bin/node") }
}
private static func loginShellNode() -> URL? {
let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
let task = Process()
task.executableURL = URL(fileURLWithPath: shell)
task.arguments = ["-lc", "command -v node"]
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = FileHandle.nullDevice
do { try task.run() } catch { return nil }
let data = pipe.fileHandleForReading.readDataToEndOfFile()
task.waitUntilExit()
let path = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return path.hasPrefix("/") ? URL(fileURLWithPath: path) : nil
}
/// `node -v`, so the window can say which runtime is about to be used.
static func nodeVersion(_ node: URL) -> String {
let task = Process()
task.executableURL = node
task.arguments = ["-v"]
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = FileHandle.nullDevice
do { try task.run() } catch { return "" }
let data = pipe.fileHandleForReading.readDataToEndOfFile()
task.waitUntilExit()
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
/// node 18 is the floor the server's package.json declares.
static func nodeIsTooOld(_ version: String) -> Bool {
let major = Int(version.dropFirst(version.hasPrefix("v") ? 1 : 0)
.prefix(while: { $0.isNumber })) ?? 0
return major > 0 && major < 18
}
// MARK: - The port (mirrors server.js: PORT env, then data dir, then checkout, then 24500)
static let defaultPort = 24500
static func port(root: URL?, dataDir: URL) -> Int {
if let fromFile = jsonNumber(at: dataDir.appendingPathComponent("proddash.json"), key: "port") {
return fromFile
}
if let root, let fromRepo = jsonNumber(at: root.appendingPathComponent("config/proddash.json"), key: "port") {
return fromRepo
}
return defaultPort
}
/**
Write the port into the data directory's proddash.json, preserving every
other key (the admin passcode lives in the same file). The launcher also
passes PORT to the server it starts, so this write is what keeps a start
by any other means on the same port.
*/
static func setPort(_ port: Int, dataDir: URL) throws {
let file = dataDir.appendingPathComponent("proddash.json")
var object: [String: Any] = [:]
if let data = try? Data(contentsOf: file),
let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
object = existing
}
object["port"] = port
try FileManager.default.createDirectory(at: dataDir, withIntermediateDirectories: true)
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
// Same atomic-ish write the server uses: never leave a half-written config.
let tmp = file.appendingPathExtension("tmp")
try Data((inHouseStyle(data) + "\n").utf8).write(to: tmp)
_ = try FileManager.default.replaceItemAt(file, withItemAt: tmp)
}
/// Foundation writes `"port" : 24500`; the server writes `"port": 24500`.
/// This file is read and hand-edited by people — keep one house style.
private static func inHouseStyle(_ json: Data) -> String {
(String(data: json, encoding: .utf8) ?? "")
.split(separator: "\n", omittingEmptySubsequences: false)
.map { line in
guard line.trimmingCharacters(in: .whitespaces).hasPrefix("\""),
let colon = line.range(of: "\" : ") else { return String(line) }
return line.replacingCharacters(in: colon, with: "\": ")
}
.joined(separator: "\n")
}
static func isValidPort(_ port: Int) -> Bool { port >= 1 && port <= 65535 }
private static func jsonNumber(at url: URL, key: String) -> Int? {
guard let data = try? Data(contentsOf: url),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let value = json[key] as? NSNumber else { return nil }
let port = value.intValue
return isValidPort(port) ? port : nil
}
// MARK: - Misc
/// "1.10.0" > "1.9.0" — the numeric comparison version strings deserve.
static func compareVersions(_ a: String, _ b: String) -> Int {
let lhs = a.split(separator: ".").map { Int($0.prefix(while: \.isNumber)) ?? 0 }
let rhs = b.split(separator: ".").map { Int($0.prefix(while: \.isNumber)) ?? 0 }
for i in 0..<max(lhs.count, rhs.count) {
let l = i < lhs.count ? lhs[i] : 0
let r = i < rhs.count ? rhs[i] : 0
if l != r { return l < r ? -1 : 1 }
}
return 0
}
}
@@ -0,0 +1,45 @@
import Foundation
/// The launcher's own preferences — everything that is about *running* ProdDash
/// rather than about ProdDash itself. (The port is not here: it belongs to
/// ProdDash and is written to its proddash.json — see LauncherConfig.)
final class LauncherSettings: ObservableObject {
private let defaults = UserDefaults.standard
@Published var rootPath: String { didSet { defaults.set(rootPath, forKey: "rootPath") } }
@Published var nodePath: String { didSet { defaults.set(nodePath, forKey: "nodePath") } }
/// Start the server as soon as the launcher opens (so a login item brings the
/// whole dashboard up by itself).
@Published var startServerAtLaunch: Bool { didSet { defaults.set(startServerAtLaunch, forKey: "startServerAtLaunch") } }
/// Bring the server back if it dies on its own. Backs off, and gives up
/// after a burst of fast crashes rather than looping forever.
@Published var restartOnCrash: Bool { didSet { defaults.set(restartOnCrash, forKey: "restartOnCrash") } }
/// Open the dashboard in a browser whenever the server comes up.
@Published var openDashboardOnStart: Bool { didSet { defaults.set(openDashboardOnStart, forKey: "openDashboardOnStart") } }
/// Show this window at launch. Off means the launcher starts straight into
/// the menu bar (Companion's "start minimised").
@Published var showWindowAtLaunch: Bool { didSet { defaults.set(showWindowAtLaunch, forKey: "showWindowAtLaunch") } }
init() {
defaults.register(defaults: [
"startServerAtLaunch": true,
"restartOnCrash": true,
"openDashboardOnStart": false,
"showWindowAtLaunch": true,
])
rootPath = defaults.string(forKey: "rootPath") ?? ""
nodePath = defaults.string(forKey: "nodePath") ?? ""
startServerAtLaunch = defaults.bool(forKey: "startServerAtLaunch")
restartOnCrash = defaults.bool(forKey: "restartOnCrash")
openDashboardOnStart = defaults.bool(forKey: "openDashboardOnStart")
showWindowAtLaunch = defaults.bool(forKey: "showWindowAtLaunch")
}
/// False until the launcher has run once — used to open the window (and ask
/// for the permissions ProdDash needs) the first time only.
var hasRunBefore: Bool {
get { defaults.bool(forKey: "hasRunBefore") }
set { defaults.set(newValue, forKey: "hasRunBefore") }
}
}
+371
View File
@@ -0,0 +1,371 @@
import AVFoundation
import AppKit
import SwiftUI
import UserNotifications
/**
The launcher window: what ProdDash is doing at the top, and the three things
an operator ever needs underneath — what it is saying (Log), how it runs
(Settings), and what macOS is letting it do (Permissions).
*/
struct LauncherView: View {
@ObservedObject var server: ServerController
@ObservedObject var settings: LauncherSettings
@ObservedObject var permissions: PermissionsModel
let onHide: () -> Void
let onQuit: () -> Void
private enum Tab: String, CaseIterable, Identifiable {
case log = "Log", settings = "Settings", permissions = "Permissions"
var id: String { rawValue }
}
@State private var tab: Tab = .log
var body: some View {
VStack(spacing: 0) {
header
Divider()
addresses
Divider()
Picker("", selection: $tab) {
ForEach(Tab.allCases) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, 16)
.padding(.vertical, 10)
Group {
switch tab {
case .log:
LogPane()
case .settings:
SettingsPane(server: server, settings: settings)
case .permissions:
PermissionsPane(server: server, permissions: permissions)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
footer
}
.frame(minWidth: 540, idealWidth: 580, maxWidth: .infinity,
minHeight: 520, idealHeight: 740, maxHeight: .infinity)
}
// MARK: - Header
private var header: some View {
HStack(spacing: 14) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 46, height: 46)
VStack(alignment: .leading, spacing: 3) {
Text("ProdDash").font(.system(size: 19, weight: .semibold))
Text(subtitle).font(.callout).foregroundStyle(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 5) {
StatusPill(state: server.state, healthy: server.healthy)
if !server.message.isEmpty {
Text(server.message)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.trailing)
.frame(maxWidth: 260, alignment: .trailing)
}
}
}
.padding(.horizontal, 16)
.padding(.top, 22)
.padding(.bottom, 14)
}
private var subtitle: String {
var parts: [String] = []
if !server.shellVersion.isEmpty { parts.append("v\(server.shellVersion)") }
if !server.nodeVersion.isEmpty { parts.append("node \(server.nodeVersion)") }
return parts.joined(separator: " · ")
}
// MARK: - Addresses and the controls that matter
private var addresses: some View {
VStack(alignment: .leading, spacing: 10) {
if server.state == .running {
AddressRow(label: "This Mac", url: server.localURL.isEmpty
? "http://localhost:\(server.port)" : server.localURL)
ForEach(server.networkURLs, id: \.self) { url in
AddressRow(label: "Network", url: url)
}
} else {
Text("Not serving. ProdDash will come up on port " + String(server.port) + ".")
.font(.callout)
.foregroundStyle(.secondary)
}
HStack(spacing: 8) {
switch server.state {
case .running, .starting:
Button("Stop") { server.stop() }
case .stopping:
Button("Stopping…") {}.disabled(true)
case .stopped, .failed:
Button("Start") { server.start() }.keyboardShortcut(.defaultAction)
}
Button("Restart") { server.restart() }
.disabled(server.state != .running && server.state != .starting)
Spacer()
Button("Open Dashboard") { server.openDashboard() }
.disabled(server.state != .running)
Button("Admin") { server.openAdmin() }
.disabled(server.state != .running)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
// MARK: - Footer
private var footer: some View {
HStack {
Button("Hide") { onHide() }
.help("Closes this window; ProdDash keeps running in the menu bar.")
Spacer()
Text("Launcher \(AppDelegate.version)")
.font(.caption)
.foregroundStyle(.tertiary)
Spacer()
Button("Quit ProdDash") { onQuit() }
.help("Stops the server and quits the launcher.")
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
// MARK: - Pieces
private struct StatusPill: View {
let state: ServerState
let healthy: Bool
var body: some View {
HStack(spacing: 7) {
Circle()
.fill(Color(nsColor: StatusIcon.color(for: state, healthy: healthy)))
.frame(width: 9, height: 9)
Text(state == .running && !healthy ? "Running (not answering)" : state.label)
.font(.system(size: 13, weight: .medium))
}
.padding(.horizontal, 11)
.padding(.vertical, 5)
.background(Color(nsColor: .quaternaryLabelColor).opacity(0.5), in: Capsule())
}
}
private struct AddressRow: View {
let label: String
let url: String
var body: some View {
HStack(spacing: 10) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 58, alignment: .leading)
Text(url)
.font(.system(.callout, design: .monospaced))
.textSelection(.enabled)
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url, forType: .string)
} label: {
Image(systemName: "doc.on.doc")
}
.buttonStyle(.borderless)
.help("Copy this address")
Spacer()
}
}
}
// MARK: - Log
private struct LogPane: View {
@ObservedObject private var log = LogStore.shared
@State private var follow = true
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(log.lines) { line in
Text(line.text)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(color(for: line.kind))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.id(line.id)
}
Color.clear.frame(height: 1).id("bottom")
}
.padding(10)
}
.background(Color(nsColor: .textBackgroundColor))
.onChange(of: log.lines.count) { _ in
guard follow else { return }
withAnimation(.none) { proxy.scrollTo("bottom", anchor: .bottom) }
}
.onAppear { proxy.scrollTo("bottom", anchor: .bottom) }
}
HStack(spacing: 8) {
Toggle("Follow", isOn: $follow).toggleStyle(.checkbox)
Spacer()
Button("Copy") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(log.plainText, forType: .string)
}
Button("Reveal Log File") {
NSWorkspace.shared.activateFileViewerSelecting([log.currentFile])
}
Button("Clear") { log.clear() }
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
}
private func color(for kind: LogStore.Kind) -> Color {
switch kind {
case .launcher: return .accentColor
case .err: return .orange
case .out: return .primary
}
}
}
// MARK: - Settings
private struct SettingsPane: View {
@ObservedObject var server: ServerController
@ObservedObject var settings: LauncherSettings
@State private var portText = ""
@State private var loginItemOn = LoginItem.isEnabled
@State private var loginNeedsApproval = LoginItem.needsApproval
var body: some View {
Form {
Section("Network") {
HStack {
TextField("Port", text: $portText)
.frame(width: 90)
.onSubmit(applyPort)
Button("Apply", action: applyPort)
.disabled(Int(portText) == server.port || Int(portText) == nil)
Spacer()
}
}
Section("Startup") {
Toggle("Open ProdDash at login", isOn: Binding(
get: { loginItemOn },
set: { setLoginItem($0) }))
if loginNeedsApproval {
HStack(spacing: 8) {
Text("macOS is holding this until you allow it.")
.font(.caption).foregroundStyle(.secondary)
Button("Open Login Items") { LoginItem.openSystemSettings() }
.controlSize(.small)
}
}
Toggle("Start the server when the launcher opens", isOn: $settings.startServerAtLaunch)
Toggle("Restart the server if it stops unexpectedly", isOn: $settings.restartOnCrash)
Toggle("Open the dashboard in a browser when the server starts", isOn: $settings.openDashboardOnStart)
Toggle("Show this window at launch", isOn: $settings.showWindowAtLaunch)
}
Section("Where things are") {
PathRow(title: "ProdDash folder",
value: server.rootURL?.path ?? "Not found — choose it",
missing: server.rootURL == nil) {
chooseFolder()
}
PathRow(title: "Node.js",
value: server.nodeURL.map { "\($0.path) \(server.nodeVersion)" } ?? "Not found",
missing: server.nodeURL == nil, action: nil)
PathRow(title: "Settings", value: server.settingsDir.isEmpty
? server.dataDir.path : server.settingsDir, missing: false, action: nil)
}
}
.formStyle(.grouped)
.onAppear { portText = String(server.port) }
.onChange(of: server.port) { newValue in portText = String(newValue) }
}
private func applyPort() {
guard let port = Int(portText) else {
portText = String(server.port)
return
}
server.applyPort(port)
portText = String(server.port)
}
private func setLoginItem(_ on: Bool) {
if let error = LoginItem.set(on) {
LogStore.shared.launcher("couldn't change the login item: \(error)")
}
loginItemOn = LoginItem.isEnabled
loginNeedsApproval = LoginItem.needsApproval
}
private func chooseFolder() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.message = "Choose the ProdDash folder — the one with server.js in it."
panel.prompt = "Choose"
guard panel.runModal() == .OK, let url = panel.url else { return }
guard ProdDash.isRoot(url) else {
let alert = NSAlert()
alert.messageText = "That isn't a ProdDash folder"
alert.informativeText = "Pick the folder that contains server.js and public/."
alert.runModal()
return
}
settings.rootPath = url.path
server.refreshEnvironment()
}
}
private struct PathRow: View {
let title: String
let value: String
let missing: Bool
var action: (() -> Void)?
var body: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
Text(title)
Text(value)
.font(.caption)
.foregroundStyle(missing ? Color.orange : Color.secondary)
.textSelection(.enabled)
.lineLimit(2)
.truncationMode(.middle)
}
Spacer()
if let action {
Button("Choose…", action: action).controlSize(.small)
}
}
}
}
@@ -0,0 +1,26 @@
import AppKit
import SwiftUI
/// The launcher's one window. Closing it leaves the app in the menu bar —
/// the same "Hide, don't Quit" habit Companion's launcher asks for.
final class LauncherWindowController: NSWindowController {
convenience init(delegate: AppDelegate) {
let view = LauncherView(server: delegate.server,
settings: delegate.settings,
permissions: delegate.permissions,
onHide: { [weak delegate] in delegate?.hideWindow() },
onQuit: { [weak delegate] in delegate?.quit() })
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 580, height: 740),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
window.title = "ProdDash"
window.titlebarAppearsTransparent = true
window.isMovableByWindowBackground = true
window.contentView = NSHostingView(rootView: view)
window.center()
window.setFrameAutosaveName("ProdDashLauncherWindow")
self.init(window: window)
}
}
+118
View File
@@ -0,0 +1,118 @@
import Foundation
/**
Everything the server prints, plus what the launcher did and why.
Kept twice over: the last few hundred lines in memory for the window's Log
tab, and a dated file under ~/Library/Logs/ProdDash so a problem from last
Sunday can still be read on Tuesday. Files older than a week are removed on
startup — the same retention Companion's launcher keeps.
*/
final class LogStore: ObservableObject {
enum Kind { case launcher, out, err }
struct Line: Identifiable {
let id = UUID()
let at: Date
let text: String
let kind: Kind
}
static let shared = LogStore()
/// Enough to cover a start-up and a fault, not enough to bloat the window.
private let capacity = 800
private let retentionDays = 7
@Published private(set) var lines: [Line] = []
private let queue = DispatchQueue(label: "org.waterschurch.proddash.launcher.log")
private var handle: FileHandle?
private var handleDay: String = ""
private lazy var directory: URL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Logs/ProdDash")
private init() {
queue.async { [weak self] in self?.pruneOldFiles() }
}
/// The launcher's own voice in the log — start/stop decisions, failures.
func launcher(_ text: String) { append(text, kind: .launcher) }
func append(_ text: String, kind: Kind) {
let line = Line(at: Date(), text: text, kind: kind)
if Thread.isMainThread {
store(line)
} else {
DispatchQueue.main.async { self.store(line) }
}
queue.async { [weak self] in self?.write(line) }
}
private func store(_ line: Line) {
lines.append(line)
if lines.count > capacity { lines.removeFirst(lines.count - capacity) }
}
func clear() {
lines.removeAll()
}
/// Today's file, for "Reveal in Finder".
var currentFile: URL { directory.appendingPathComponent("proddash-\(Self.dayFormatter.string(from: Date())).log") }
var plainText: String {
lines.map { "\(Self.timeFormatter.string(from: $0.at)) \($0.text)" }.joined(separator: "\n")
}
// MARK: - File
private func write(_ line: Line) {
let day = Self.dayFormatter.string(from: line.at)
if handle == nil || handleDay != day {
try? handle?.close()
handle = nil
handleDay = day
let file = directory.appendingPathComponent("proddash-\(day).log")
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
if !FileManager.default.fileExists(atPath: file.path) {
FileManager.default.createFile(atPath: file.path, contents: nil)
}
handle = try? FileHandle(forWritingTo: file)
_ = try? handle?.seekToEnd()
}
let prefix = line.kind == .launcher ? "[launcher] " : ""
let text = "\(Self.stampFormatter.string(from: line.at)) \(prefix)\(line.text)\n"
try? handle?.write(contentsOf: Data(text.utf8))
}
private func pruneOldFiles() {
let cutoff = Date().addingTimeInterval(-Double(retentionDays) * 86400)
guard let files = try? FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: [.contentModificationDateKey]) else { return }
for file in files where file.lastPathComponent.hasPrefix("proddash-") {
let modified = (try? file.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate
if let modified, modified < cutoff { try? FileManager.default.removeItem(at: file) }
}
}
private static let dayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
return f
}()
private static let timeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm:ss"
return f
}()
private static let stampFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
return f
}()
}
+125
View File
@@ -0,0 +1,125 @@
import Foundation
import Network
/**
Small connection probes, used for two jobs:
* is something already listening on ProdDash's port (a server started over
SSH or from the .command file, which the launcher offers to take over), and
* can this machine actually reach the gear the modules are configured for —
which on macOS 15+ is the only practical way to tell whether the Local
Network permission was granted, because there is no API that reports it.
Network.framework rather than a bare socket on purpose: a blocked local
network connection surfaces here as a plain unreachable/timeout instead of a
silent hang.
*/
enum Net {
enum Probe: Equatable {
case open // something accepted the connection
case refused // nothing listening, but the host answered
case unreachable // no route — or macOS is withholding local network access
case timedOut
case failed(String)
var isReachableHost: Bool { self == .open || self == .refused }
}
/// Probe host:port, calling back on the main queue exactly once.
static func probe(host: String, port: UInt16, timeout: TimeInterval = 2.0,
completion: @escaping (Probe) -> Void) {
guard let nwPort = NWEndpoint.Port(rawValue: port) else {
return DispatchQueue.main.async { completion(.failed("invalid port")) }
}
let tcp = NWProtocolTCP.Options()
tcp.connectionTimeout = max(1, Int(timeout))
tcp.noDelay = true
let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort,
using: NWParameters(tls: nil, tcp: tcp))
var settled = false
let finish: (Probe) -> Void = { result in
guard !settled else { return }
settled = true
connection.cancel()
DispatchQueue.main.async { completion(result) }
}
connection.stateUpdateHandler = { state in
switch state {
case .ready:
finish(.open)
case .waiting(let error):
// "waiting" means it would keep retrying — for a probe that is the answer.
finish(map(error))
case .failed(let error):
finish(map(error))
case .cancelled:
finish(.failed("cancelled"))
default:
break
}
}
connection.start(queue: .global(qos: .utility))
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + timeout) { finish(.timedOut) }
}
/// Blocking form, for preflight on a background queue. Never call it on main.
static func probeSync(host: String, port: UInt16, timeout: TimeInterval = 2.0) -> Probe {
let semaphore = DispatchSemaphore(value: 0)
var result: Probe = .timedOut
probe(host: host, port: port, timeout: timeout) { outcome in
result = outcome
semaphore.signal()
}
_ = semaphore.wait(timeout: .now() + timeout + 1.0)
return result
}
private static func map(_ error: NWError) -> Probe {
if case .posix(let code) = error {
switch code {
case .ECONNREFUSED: return .refused
case .EHOSTUNREACH, .ENETUNREACH, .EHOSTDOWN, .ENETDOWN: return .unreachable
case .ETIMEDOUT: return .timedOut
default: return .failed(String(describing: code))
}
}
return .failed(error.localizedDescription)
}
/// Is this address on this building's network (so the Local Network
/// permission applies) rather than out on the internet?
static func isLocalAddress(_ host: String) -> Bool {
let name = host.lowercased()
if name == "localhost" || name.hasSuffix(".local") || name.hasSuffix(".lan") { return true }
let parts = name.split(separator: ".").map(String.init)
guard parts.count == 4, let a = Int(parts[0]), let b = Int(parts[1]),
parts.allSatisfy({ Int($0) != nil }) else {
// A bare hostname with no dots is a LAN name; anything else is the internet.
return !name.contains(".")
}
if a == 10 || a == 127 { return true }
if a == 192 && b == 168 { return true }
if a == 172 && (16...31).contains(b) { return true }
if a == 169 && b == 254 { return true }
return false
}
/// PIDs listening on a TCP port, so a stray ProdDash can be handed over.
static func listenerPIDs(port: Int) -> [pid_t] {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof")
task.arguments = ["-t", "-iTCP:\(port)", "-sTCP:LISTEN"]
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = FileHandle.nullDevice
do { try task.run() } catch { return [] }
let data = pipe.fileHandleForReading.readDataToEndOfFile()
task.waitUntilExit()
return (String(data: data, encoding: .utf8) ?? "")
.split(whereSeparator: \.isNewline)
.compactMap { pid_t($0.trimmingCharacters(in: .whitespaces)) }
}
}
+324
View File
@@ -0,0 +1,324 @@
import AVFoundation
import Foundation
import Network
import UserNotifications
/**
The permissions ProdDash's modules need, and this app's job of holding them.
A module can say what it needs in its manifest, which is how this pane knows
to mention the microphone at all:
"permissions": [
{ "kind": "microphone", "reason": "Decoding LTC timecode from an audio input" }
]
The shell ignores the key; only the launcher reads it. A bare
`"permissions": ["microphone"]` works too, and a kind this launcher doesn't
manage is still listed — better to name it than to pretend it isn't needed.
*/
enum PermissionKind: Hashable {
case microphone
case localNetwork
case other(String)
init(raw: String) {
switch raw.lowercased().replacingOccurrences(of: " ", with: "-")
.replacingOccurrences(of: "_", with: "-") {
case "microphone", "audio-input", "mic": self = .microphone
case "local-network", "localnetwork", "lan": self = .localNetwork
case let other: self = .other(other)
}
}
var id: String {
switch self {
case .microphone: return "microphone"
case .localNetwork: return "local-network"
case .other(let raw): return raw
}
}
var title: String {
switch self {
case .microphone: return "Microphone"
case .localNetwork: return "Local Network"
case .other(let raw): return raw.capitalized
}
}
/// Every module that needs LTC needs the same thing; this is the standing
/// explanation when a module doesn't give its own.
var defaultReason: String {
switch self {
case .microphone:
return "Reading an audio input on this machine."
case .localNetwork:
return "Reaching production gear on this network."
case .other:
return "Required by a module."
}
}
}
/// Why one set of modules needs a permission. Modules that give the same
/// reason are listed together, so three modules reaching the same network
/// don't produce three near-identical lines.
struct PermissionClaim: Identifiable, Equatable {
let reason: String
var modules: [String]
var id: String { reason }
var askedBy: String { modules.joined(separator: ", ") }
}
/// One permission, and everyone who asked for it.
struct ModuleNeed: Identifiable, Equatable {
let kind: PermissionKind
var claims: [PermissionClaim] = []
var id: String { kind.id }
var modules: [String] {
var seen: [String] = []
for claim in claims where !claim.modules.isEmpty {
for module in claim.modules where !seen.contains(module) { seen.append(module) }
}
return seen
}
}
/// A configured upstream and whether this machine can reach it.
struct HostCheck: Identifiable {
let id = UUID()
let module: String
let host: String
let port: Int
var result: Net.Probe?
var label: String { "\(host):\(port)" }
}
final class PermissionsModel: ObservableObject {
@Published private(set) var needs: [ModuleNeed] = []
@Published private(set) var microphone: AVAuthorizationStatus = .notDetermined
@Published private(set) var notifications: UNAuthorizationStatus = .notDetermined
@Published private(set) var hostChecks: [HostCheck] = []
@Published private(set) var checking = false
private var browser: NWBrowser?
// MARK: - Status
func refresh(root: URL?, dataDir: URL) {
let status = AVCaptureDevice.authorizationStatus(for: .audio)
if status != microphone { microphone = status }
Notifier.shared.refreshStatus { [weak self] status in
if status != self?.notifications { self?.notifications = status }
}
let scanned = Self.scanModules(root: root, dataDir: dataDir)
if scanned != needs { needs = scanned }
if hostChecks.isEmpty {
let hosts = Self.configuredLocalHosts(root: root, dataDir: dataDir)
if !hosts.isEmpty { hostChecks = hosts }
}
}
func requestMicrophone() {
guard microphone == .notDetermined else {
return SystemSettings.open(SystemSettings.microphone)
}
AVCaptureDevice.requestAccess(for: .audio) { [weak self] _ in
DispatchQueue.main.async {
self?.microphone = AVCaptureDevice.authorizationStatus(for: .audio)
LogStore.shared.launcher("microphone access: \(self?.microphone.description ?? "?")")
}
}
}
func requestNotifications() {
guard notifications == .notDetermined else {
return SystemSettings.open(SystemSettings.notifications)
}
Notifier.shared.requestAuthorization { [weak self] _ in
Notifier.shared.refreshStatus { status in self?.notifications = status }
}
}
// MARK: - Local network
/**
macOS never reports whether local network access was granted, so this
does the only thing that actually answers the question: touch the network
the way the modules do — a Bonjour browse (which is what raises the
prompt) and a connection attempt to each configured upstream — then say
what the results mean.
*/
func checkLocalNetwork(root: URL?, dataDir: URL) {
guard !checking else { return }
var checks = Self.configuredLocalHosts(root: root, dataDir: dataDir)
checking = true
for index in checks.indices { checks[index].result = nil }
hostChecks = checks
startBonjourBrowse()
guard !checks.isEmpty else {
// Nothing configured yet: the browse alone still raises the prompt.
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in
self?.checking = false
LogStore.shared.launcher("local network check: nothing configured to try yet")
}
return
}
let group = DispatchGroup()
for (index, check) in checks.enumerated() {
group.enter()
Net.probe(host: check.host, port: UInt16(check.port), timeout: 3) { [weak self] result in
self?.hostChecks[index].result = result
group.leave()
}
}
group.notify(queue: .main) { [weak self] in
guard let self else { return }
self.checking = false
self.stopBonjourBrowse()
let results = self.hostChecks.compactMap(\.result)
let answered = results.filter(\.isReachableHost).count
let verdict: String
if answered == results.count {
verdict = "every configured host answered"
} else if answered == 0 {
verdict = "nothing answered — if the gear is on, macOS is withholding local network access"
} else {
verdict = "\(answered) of \(results.count) hosts answered"
}
LogStore.shared.launcher("local network check: \(verdict)")
}
}
/// Browsing for Bonjour services is what makes macOS raise the local
/// network prompt; the results themselves don't matter here.
private func startBonjourBrowse() {
stopBonjourBrowse()
let browser = NWBrowser(for: .bonjour(type: "_http._tcp", domain: nil), using: .tcp)
browser.start(queue: .global(qos: .utility))
self.browser = browser
DispatchQueue.main.asyncAfter(deadline: .now() + 6) { [weak self] in self?.stopBonjourBrowse() }
}
private func stopBonjourBrowse() {
browser?.cancel()
browser = nil
}
// MARK: - Reading the modules
/// Manifests from both places the shell looks: the checkout and the
/// modules installed from the admin page.
private static func moduleManifests(root: URL?, dataDir: URL) -> [(id: String, name: String, json: [String: Any])] {
var found: [String: (String, [String: Any])] = [:]
for base in [root?.appendingPathComponent("modules"), dataDir.appendingPathComponent("modules")].compactMap({ $0 }) {
let dirs = (try? FileManager.default.contentsOfDirectory(atPath: base.path)) ?? []
for dir in dirs where !dir.hasPrefix(".") {
let file = base.appendingPathComponent(dir).appendingPathComponent("module.json")
guard let data = try? Data(contentsOf: file),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue }
found[dir] = (json["name"] as? String ?? dir, json)
}
}
return found.map { (id: $0.key, name: $0.value.0, json: $0.value.1) }
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
static func scanModules(root: URL?, dataDir: URL) -> [ModuleNeed] {
var byKind: [PermissionKind: ModuleNeed] = [:]
for module in moduleManifests(root: root, dataDir: dataDir) {
guard let raw = module.json["permissions"] as? [Any] else { continue }
for entry in raw {
var kind: PermissionKind?
var reason = ""
if let name = entry as? String {
kind = PermissionKind(raw: name)
} else if let object = entry as? [String: Any], let name = object["kind"] as? String {
kind = PermissionKind(raw: name)
reason = (object["reason"] as? String ?? "").trimmingCharacters(in: .whitespaces)
}
guard let kind else { continue }
var need = byKind[kind] ?? ModuleNeed(kind: kind)
let text = reason.isEmpty ? kind.defaultReason : reason
if let index = need.claims.firstIndex(where: { $0.reason == text }) {
if !need.claims[index].modules.contains(module.name) {
need.claims[index].modules.append(module.name)
}
} else {
need.claims.append(PermissionClaim(reason: text, modules: [module.name]))
}
byKind[kind] = need
}
}
// Stable order: the two the launcher manages first, then anything else.
let order: [PermissionKind] = [.microphone, .localNetwork]
return byKind.values.sorted {
let a = order.firstIndex(of: $0.kind) ?? order.count
let b = order.firstIndex(of: $1.kind) ?? order.count
return a == b ? $0.kind.title < $1.kind.title : a < b
}
}
/**
The upstreams the admin page has been pointed at, pulled out of the
module config the server writes (modules.json). Endpoint settings are
`{ host, port }` objects and URLs are strings — both shapes appear, and
both are worth testing, so this walks the config rather than knowing any
particular module's key names.
*/
static func configuredLocalHosts(root: URL?, dataDir: URL) -> [HostCheck] {
guard let data = try? Data(contentsOf: dataDir.appendingPathComponent("modules.json")),
let config = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [] }
let names = Dictionary(uniqueKeysWithValues: moduleManifests(root: root, dataDir: dataDir).map { ($0.id, $0.name) })
var checks: [HostCheck] = []
var seen = Set<String>()
func consider(_ host: String, _ port: Int, module: String) {
let host = host.trimmingCharacters(in: .whitespaces)
guard !host.isEmpty, Net.isLocalAddress(host), host != "localhost", host != "127.0.0.1" else { return }
let key = "\(host):\(port)"
guard !seen.contains(key) else { return }
seen.insert(key)
checks.append(HostCheck(module: module, host: host, port: port))
}
func walk(_ value: Any, module: String) {
if let object = value as? [String: Any] {
if let host = object["host"] as? String {
consider(host, (object["port"] as? NSNumber)?.intValue ?? 80, module: module)
}
for nested in object.values { walk(nested, module: module) }
} else if let list = value as? [Any] {
for nested in list { walk(nested, module: module) }
} else if let text = value as? String, text.contains("://"),
let parts = URLComponents(string: text), let host = parts.host {
consider(host, parts.port ?? (parts.scheme == "https" ? 443 : 80), module: module)
}
}
for (id, entry) in config {
guard let entry = entry as? [String: Any], let moduleConfig = entry["config"] else { continue }
walk(moduleConfig, module: names[id] ?? id)
}
return checks.sorted { $0.module < $1.module }
}
}
extension AVAuthorizationStatus {
var description: String {
switch self {
case .authorized: return "granted"
case .denied: return "denied"
case .restricted: return "restricted"
case .notDetermined: return "not asked yet"
@unknown default: return "unknown"
}
}
}
@@ -0,0 +1,234 @@
import AVFoundation
import SwiftUI
import UserNotifications
/**
The permissions pane: one line per permission — a status dot, its name, and
the one thing you can do about it.
What appears here is what the installed modules declare in their manifests,
so the list matches what is actually installed. Each row's tooltip carries
the modules' own reasons; the row itself stays out of the way.
*/
struct PermissionsPane: View {
@ObservedObject var server: ServerController
@ObservedObject var permissions: PermissionsModel
var body: some View {
VStack(spacing: 0) {
Form {
Section {
if permissions.needs.isEmpty {
Text("No installed module declares a permission.")
.foregroundStyle(.secondary)
}
ForEach(permissions.needs) { need in
row(for: need)
}
notifications
}
}
.formStyle(.grouped)
Button("Re-check") {
permissions.refresh(root: server.rootURL, dataDir: server.dataDir)
}
.padding(.bottom, 14)
}
.onAppear { permissions.refresh(root: server.rootURL, dataDir: server.dataDir) }
}
// MARK: - Rows
@ViewBuilder
private func row(for need: ModuleNeed) -> some View {
switch need.kind {
case .microphone:
PermissionRow(title: need.kind.title,
tone: microphoneTone,
help: help(for: need),
actionTitle: microphoneActionTitle,
action: microphoneActionTitle == nil ? nil : { requestMicrophone() })
case .localNetwork:
VStack(alignment: .leading, spacing: 7) {
PermissionRow(title: need.kind.title,
tone: localNetworkTone,
help: help(for: need),
busy: permissions.checking,
actionTitle: "Check Access",
action: { permissions.checkLocalNetwork(root: server.rootURL, dataDir: server.dataDir) })
if !results.isEmpty { hostResults }
}
case .other:
PermissionRow(title: need.kind.title,
tone: .unknown,
help: "The launcher doesn't manage this one — grant it in System Settings.\n"
+ help(for: need),
actionTitle: "Open System Settings",
action: { SystemSettings.open(SystemSettings.privacy) })
}
}
private var notifications: some View {
PermissionRow(title: "Notifications",
tone: notificationTone,
help: "Tells you when ProdDash stops on its own.",
actionTitle: notificationActionTitle,
action: notificationActionTitle == nil ? nil : { permissions.requestNotifications() })
}
/// What the modules said, for the tooltip — one line per distinct reason.
private func help(for need: ModuleNeed) -> String {
need.claims
.map { $0.modules.isEmpty ? $0.reason : "\($0.reason) — \($0.askedBy)" }
.joined(separator: "\n")
}
// MARK: - Local network results
private var results: [Net.Probe] { permissions.hostChecks.compactMap(\.result) }
private var hostResults: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(permissions.hostChecks) { check in
HStack(spacing: 8) {
Circle()
.fill(tone(for: check.result).color)
.frame(width: 6, height: 6)
Text(check.label)
.font(.system(.caption, design: .monospaced))
Spacer()
Text(describe(check.result))
.font(.caption)
.foregroundStyle(.secondary)
}
}
if !results.isEmpty, !results.contains(where: \.isReachableHost) {
// Only when everything failed, because that is the shape a
// withheld permission takes.
HStack(spacing: 8) {
Text("Nothing answered.")
.font(.caption)
.foregroundStyle(.secondary)
Button("Open System Settings") { SystemSettings.open(SystemSettings.localNetwork) }
.buttonStyle(.link)
.controlSize(.small)
}
}
}
.padding(.leading, 17)
}
private func describe(_ result: Net.Probe?) -> String {
switch result {
case .none: return "—"
case .open: return "answered"
case .refused: return "reachable"
case .unreachable: return "no route"
case .timedOut: return "no answer"
case .failed(let why): return why
}
}
private func tone(for result: Net.Probe?) -> Tone {
guard let result else { return .unknown }
return result.isReachableHost ? .good : .bad
}
// MARK: - Status and actions
private func requestMicrophone() {
permissions.requestMicrophone()
}
private var microphoneTone: Tone {
switch permissions.microphone {
case .authorized: return .good
case .denied, .restricted: return .bad
default: return .unknown
}
}
private var microphoneActionTitle: String? {
switch permissions.microphone {
case .authorized: return nil
case .notDetermined: return "Allow"
default: return "Open System Settings"
}
}
/// Anything answering means the permission is there; only a clean sweep of
/// failures points at macOS withholding it.
private var localNetworkTone: Tone {
guard !results.isEmpty else { return .unknown }
return results.contains(where: \.isReachableHost) ? .good : .bad
}
private var notificationTone: Tone {
switch permissions.notifications {
case .authorized, .provisional, .ephemeral: return .good
case .denied: return .bad
default: return .unknown
}
}
private var notificationActionTitle: String? {
switch permissions.notifications {
case .authorized, .provisional, .ephemeral: return nil
case .notDetermined: return "Enable"
default: return "Open System Settings"
}
}
}
// MARK: - Pieces
enum Tone {
case good, bad, unknown
var color: Color {
switch self {
case .good: return Color(nsColor: StatusIcon.accent)
case .bad: return Color(nsColor: StatusIcon.danger)
case .unknown: return .secondary
}
}
/// Said out loud for anyone who can't see the dot.
var word: String {
switch self {
case .good: return "allowed"
case .bad: return "not allowed"
case .unknown: return "not checked"
}
}
}
private struct PermissionRow: View {
let title: String
let tone: Tone
let help: String
var busy = false
var actionTitle: String?
var action: (() -> Void)?
var body: some View {
HStack(spacing: 9) {
Circle()
.fill(tone.color)
.frame(width: 8, height: 8)
.accessibilityHidden(true) // the name carries the status instead
Text(title)
.accessibilityLabel("\(title): \(tone.word)")
Spacer()
if busy { ProgressView().controlSize(.small) }
if let actionTitle, let action {
Button(actionTitle, action: action).disabled(busy)
}
}
.help(help)
}
}
@@ -0,0 +1,446 @@
import AppKit
import Foundation
enum ServerState: Equatable {
case stopped
case starting
case running
case stopping
case failed(String)
var isBusy: Bool { self == .starting || self == .running || self == .stopping }
var label: String {
switch self {
case .stopped: return "Stopped"
case .starting: return "Starting…"
case .running: return "Running"
case .stopping: return "Stopping…"
case .failed: return "Problem"
}
}
}
/**
Starts, stops and watches over the ProdDash server.
The shell was built for this: it takes `PRODDASH_LAUNCHER=1` to mean "someone
is supervising me", and then exits **75** instead of respawning itself when
the admin page applies an update — so a 75 here is a restart request, not a
fault. Anything else unexpected is a crash: it gets a backed-off restart, and
a burst of fast crashes stops the cycle rather than looping forever.
Starting the server from this app is also what gives the modules their
permissions. macOS hands a child process the *responsible process* of its
parent, so node — and the `ltc-capture` tool the Timers module runs — inherit
this app bundle's identity, and the microphone and local-network prompts are
asked, and remembered, in ProdDash's name. A server started over SSH or from
a bare launchd job has no such identity, which is why its LTC input is
silently all zeros.
*/
final class ServerController: ObservableObject {
@Published private(set) var state: ServerState = .stopped
/// One line under the status pill: why it stopped, what to fix.
@Published private(set) var message = ""
@Published private(set) var port: Int = ProdDash.defaultPort
@Published private(set) var shellVersion = ""
@Published private(set) var localURL = ""
@Published private(set) var networkURLs: [String] = []
@Published private(set) var settingsDir = ""
/// The server answered /api/modules — listening, not merely alive.
@Published private(set) var healthy = false
@Published private(set) var rootURL: URL?
@Published private(set) var nodeURL: URL?
@Published private(set) var nodeVersion = ""
/// Asked when the port is already taken: (port, looks like ProdDash, reply).
var onPortBusy: ((Int, Bool, @escaping (Bool) -> Void) -> Void)?
private let settings: LauncherSettings
private let log = LogStore.shared
private var child: ChildProcess?
private var intentionalStop = false
private var restartAfterExit = false
private var startedAt: Date?
private var crashTimes: [Date] = []
/// One roll-back per bad update: a second would just swap copies forever.
private var rolledBack = false
private var pendingRestart: DispatchWorkItem?
private var healthTimer: Timer?
private var healthMisses = 0
private var openedDashboardThisRun = false
/// The most recent line that reads like a reason, shown with a failure.
private var lastProblemLine = ""
init(settings: LauncherSettings) {
self.settings = settings
refreshEnvironment()
}
// MARK: - Environment
/// Re-resolve the checkout, the node binary and the port. Cheap, and run
/// again before every start so a folder fixed in Settings takes effect.
func refreshEnvironment() {
// A shipped app installs (or re-installs) its copy of ProdDash before
// going looking for one. An explicitly chosen folder skips all that.
let base = ProdDash.dataDir(root: nil)
if settings.rootPath.isEmpty {
ProdDash.seedWorkingCopy(dataDir: base) { [log] message in log.launcher(message) }
}
let root = ProdDash.findRoot(preferred: settings.rootPath, dataDir: base)
rootURL = root
let node = ProdDash.findNode(root: root, preferred: settings.nodePath)
if node?.path != nodeURL?.path {
nodeURL = node
nodeVersion = node.map(ProdDash.nodeVersion) ?? ""
}
if let root {
let version = ProdDash.shellVersion(root: root)
if !version.isEmpty { shellVersion = version }
}
if !state.isBusy {
port = ProdDash.port(root: root, dataDir: dataDir)
}
}
var dataDir: URL { ProdDash.dataDir(root: rootURL) }
var dashboardURL: URL { URL(string: "http://localhost:\(port)/")! }
var adminURL: URL { URL(string: "http://localhost:\(port)/admin")! }
// MARK: - Start
func start() {
guard !state.isBusy else { return }
pendingRestart?.cancel()
pendingRestart = nil
refreshEnvironment()
guard let root = rootURL else {
return fail("Can't find the ProdDash folder. Choose it under Settings.")
}
guard let node = nodeURL else {
return fail("Node.js 18 or newer isn't installed. Install it from nodejs.org, or set the path under Settings.")
}
if ProdDash.nodeIsTooOld(nodeVersion) {
return fail("This machine's node is \(nodeVersion) — ProdDash needs 18 or newer.")
}
state = .starting
message = ""
localURL = ""
networkURLs = []
healthy = false
healthMisses = 0
openedDashboardThisRun = false
lastProblemLine = ""
let port = self.port
// The port probe and the "is that ProdDash?" request both block; keep
// them off the main queue so the window stays live.
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
let busy = Net.probeSync(host: "127.0.0.1", port: UInt16(port), timeout: 1.0) == .open
let isProdDash = busy && Self.respondsAsProdDash(port: port)
DispatchQueue.main.async {
guard let self, self.state == .starting else { return }
guard busy else { return self.spawn(root: root, node: node, port: port) }
guard let ask = self.onPortBusy else {
return self.fail("Port \(port) is already in use.")
}
ask(port, isProdDash) { takeOver in
guard takeOver else {
self.state = .stopped
self.message = "Port \(port) is already in use."
return
}
self.takeOverPort(port)
self.spawn(root: root, node: node, port: port)
}
}
}
}
private func spawn(root: URL, node: URL, port: Int) {
var env = ProcessInfo.processInfo.environment
env["PRODDASH_LAUNCHER"] = "1" // "someone is supervising me" — see the note above
env["PORT"] = String(port)
// A GUI app inherits almost no PATH. Put node's own folder first, and
// keep the usual system ones so the Timers module can still find swiftc
// when it needs to build its capture tool.
let base = env["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin"
env["PATH"] = node.deletingLastPathComponent().path + ":" + base + ":/usr/local/bin:/opt/homebrew/bin"
log.launcher("starting: \(node.path) server.js (in \(root.path), port \(port))")
do {
child = try ChildProcess.run(
executable: node.path,
arguments: ["server.js"],
directory: root.path,
environment: env,
onLine: { [weak self] line, isError in self?.handleOutput(line, isError: isError) },
onExit: { [weak self] code, bySignal in self?.handleExit(code: code, bySignal: bySignal) })
intentionalStop = false
startedAt = Date()
startHealthPolling()
} catch {
fail(error.localizedDescription)
}
}
/// Hand the port over from a ProdDash somebody started another way. The
/// server we are about to start waits for the port to free up on its own.
private func takeOverPort(_ port: Int) {
let pids = Net.listenerPIDs(port: port)
guard !pids.isEmpty else { return }
log.launcher("taking port \(port) over from pid \(pids.map(String.init).joined(separator: ", "))")
for pid in pids { kill(pid, SIGTERM) }
Thread.sleep(forTimeInterval: 0.6)
}
// MARK: - Stop / restart
func stop() {
pendingRestart?.cancel()
pendingRestart = nil
guard let child else {
state = .stopped
return
}
intentionalStop = true
state = .stopping
message = ""
log.launcher("stopping ProdDash")
child.terminate()
let pid = child.pid
// The server closes in about a second and a half; this is the backstop.
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
guard let self, let current = self.child, current.pid == pid, current.isAlive else { return }
self.log.launcher("ProdDash didn't stop on its own after 8s — forcing it")
current.forceKill()
}
}
func restart() {
guard child != nil else { return start() }
restartAfterExit = true
stop()
}
func toggle() {
switch state {
case .running, .starting: stop()
case .stopped, .failed: start()
case .stopping: break
}
}
private func scheduleRestart(after seconds: TimeInterval) {
let work = DispatchWorkItem { [weak self] in self?.start() }
pendingRestart = work
DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: work)
}
private func fail(_ reason: String) {
state = .failed(reason)
message = reason
log.launcher(reason)
}
// MARK: - Child output
private func handleOutput(_ line: String, isError: Bool) {
log.append(line, kind: isError ? .err : .out)
if line.contains("cannot start") || line.contains("Error:") || line.contains("EADDRINUSE") {
lastProblemLine = line.trimmingCharacters(in: .whitespaces)
}
parseBanner(line)
}
/**
The server prints a short banner once it is listening:
ProdDash 1.3.0
Local : http://localhost:24500
Network : http://10.0.1.42:24500
Modules : clock, prodcom-transcript, …
Settings : /Users/booth/Library/Application Support/ProdDash
Reading it is how the launcher learns it is really up (before the health
poll gets there) and what to show in the window.
*/
private func parseBanner(_ line: String) {
let text = line.trimmingCharacters(in: .whitespaces)
if text.hasPrefix("ProdDash "), text.dropFirst(9).first?.isNumber == true {
shellVersion = String(text.dropFirst(9)).trimmingCharacters(in: .whitespaces)
if state == .starting {
state = .running
message = ""
log.launcher("ProdDash \(shellVersion) is up on port \(port)")
if settings.openDashboardOnStart { openDashboard() }
openedDashboardThisRun = true
}
return
}
guard let colon = text.firstIndex(of: ":") else { return }
let field = text[text.startIndex..<colon].trimmingCharacters(in: .whitespaces)
let value = text[text.index(after: colon)...].trimmingCharacters(in: .whitespaces)
switch field {
case "Local": localURL = value
case "Network": if !networkURLs.contains(value) { networkURLs.append(value) }
case "Settings": settingsDir = value
default: break
}
}
// MARK: - Exit
private func handleExit(code: Int32, bySignal: Bool) {
stopHealthPolling()
child = nil
healthy = false
let ranFor = startedAt.map { Date().timeIntervalSince($0) } ?? 0
if ranFor > 60 { crashTimes.removeAll(); rolledBack = false } // a long healthy run clears the slate
if restartAfterExit {
restartAfterExit = false
state = .stopped
start()
return
}
if intentionalStop {
state = .stopped
message = "Stopped."
log.launcher("ProdDash stopped")
return
}
// 75 is the shell's "start me again" — the admin page applied an update.
if !bySignal && code == 75 {
log.launcher("ProdDash applied an update and asked to be restarted")
state = .stopped
message = "Restarting to apply an update…"
start()
return
}
let how = bySignal ? "was killed by signal \(code)" : "exited with code \(code)"
let detail = lastProblemLine.isEmpty ? "" : " — \(lastProblemLine)"
crashTimes.append(Date())
crashTimes = crashTimes.filter { Date().timeIntervalSince($0) < 120 }
guard settings.restartOnCrash, crashTimes.count < 4 else {
// Before giving up: if an update is still unproven, the copy it
// replaced is sitting right there. Going back beats sitting dark,
// and only once — if the old one will not run either, the problem
// is not the update.
if settings.restartOnCrash, !rolledBack, let root = rootURL,
let restored = ProdDash.restorePrevious(root: root, log: { [log] in log.launcher($0) }) {
rolledBack = true
crashTimes.removeAll()
state = .stopped
message = "Rolled back to ProdDash \(restored) — starting…"
Notifier.shared.post(title: "ProdDash rolled back",
body: "The new version would not stay up. Back on \(restored).")
refreshEnvironment()
start()
return
}
let why = settings.restartOnCrash
? "ProdDash stopped \(crashTimes.count) times in two minutes — leaving it stopped."
: "ProdDash \(how)."
let last = lastProblemLine.isEmpty ? "" : " Last message: \(lastProblemLine)"
fail(why + last)
Notifier.shared.post(title: "ProdDash stopped", body: why)
return
}
let delay = [1.0, 2.0, 5.0, 10.0][min(crashTimes.count - 1, 3)]
state = .stopped
message = "ProdDash \(how) — restarting…"
log.launcher("ProdDash \(how)\(detail); restarting in \(Int(delay))s")
Notifier.shared.post(title: "ProdDash stopped", body: "It \(how)\(detail). Restarting…")
scheduleRestart(after: delay)
}
// MARK: - Health
private func startHealthPolling() {
healthTimer?.invalidate()
let timer = Timer(timeInterval: 3, repeats: true) { [weak self] _ in self?.pollHealth() }
RunLoop.main.add(timer, forMode: .common)
healthTimer = timer
}
private func stopHealthPolling() {
healthTimer?.invalidate()
healthTimer = nil
}
private func pollHealth() {
let port = self.port
DispatchQueue.global(qos: .utility).async { [weak self] in
let ok = Self.respondsAsProdDash(port: port)
DispatchQueue.main.async {
guard let self, self.child != nil else { return }
if ok {
self.healthMisses = 0
self.healthy = true
if self.state == .starting {
self.state = .running
if self.settings.openDashboardOnStart, !self.openedDashboardThisRun {
self.openedDashboardThisRun = true
self.openDashboard()
}
}
} else {
self.healthMisses += 1
if self.healthMisses >= 2 { self.healthy = false }
}
}
}
}
/// Does something on this port answer ProdDash's own unauthenticated
/// module list? Blocking — background queues only.
static func respondsAsProdDash(port: Int) -> Bool {
guard let url = URL(string: "http://127.0.0.1:\(port)/api/modules") else { return false }
var request = URLRequest(url: url)
request.timeoutInterval = 2
request.httpMethod = "GET"
let semaphore = DispatchSemaphore(value: 0)
var ok = false
URLSession.shared.dataTask(with: request) { data, response, _ in
defer { semaphore.signal() }
guard let http = response as? HTTPURLResponse, http.statusCode == 200,
let data, let body = String(data: data, encoding: .utf8) else { return }
ok = body.contains("\"modules\"")
}.resume()
_ = semaphore.wait(timeout: .now() + 3)
return ok
}
// MARK: - Settings ProdDash owns
/// Write the port where ProdDash reads it, then restart if it is running.
func applyPort(_ newPort: Int) {
guard ProdDash.isValidPort(newPort) else {
message = "\(newPort) isn't a usable port number."
return
}
guard newPort != port else { return }
do {
try ProdDash.setPort(newPort, dataDir: dataDir)
port = newPort
log.launcher("port set to \(newPort)")
if child != nil { restart() }
} catch {
message = "Couldn't save the port: \(error.localizedDescription)"
log.launcher(message)
}
}
// MARK: - Opening things
func openDashboard() { NSWorkspace.shared.open(dashboardURL) }
func openAdmin() { NSWorkspace.shared.open(adminURL) }
}
+67
View File
@@ -0,0 +1,67 @@
import AppKit
/**
The menu-bar glyph: ProdDash's own icon — a 2×2 grid of tiles — with the
accent tile carrying the state, exactly as the app icon does. Filled and
green while it serves; hollow and dim when stopped; amber mid-move; red when
something needs a person.
Drawn rather than templated because a template image can only be one colour.
The outline uses `labelColor`, so it follows a light or dark menu bar; the
status item redraws it when the appearance changes.
*/
enum StatusIcon {
static let accent = NSColor(srgbRed: 0.18, green: 0.90, blue: 0.60, alpha: 1) // #2ee59a, the shell's --accent
static let warn = NSColor(srgbRed: 0.94, green: 0.71, blue: 0.16, alpha: 1) // #f0b429
static let danger = NSColor(srgbRed: 0.96, green: 0.26, blue: 0.24, alpha: 1) // #f4433c
static func color(for state: ServerState, healthy: Bool) -> NSColor {
switch state {
case .running: return healthy ? accent : warn
case .starting, .stopping: return warn
case .failed: return danger
case .stopped: return NSColor.secondaryLabelColor
}
}
static func image(for state: ServerState, healthy: Bool) -> NSImage {
let side: CGFloat = 18
let fill: NSColor? = {
switch state {
case .stopped: return nil // nothing lit: plainly off
default: return color(for: state, healthy: healthy)
}
}()
let image = NSImage(size: NSSize(width: side, height: side), flipped: false) { _ in
let inset: CGFloat = 1.5
let gap: CGFloat = 1.6
let tile = (side - inset * 2 - gap) / 2
let outline = NSColor.labelColor.withAlphaComponent(state == .stopped ? 0.55 : 0.9)
for row in 0..<2 {
for column in 0..<2 {
let rect = NSRect(x: inset + CGFloat(column) * (tile + gap),
y: inset + CGFloat(row) * (tile + gap),
width: tile, height: tile)
// Bottom-right is the accent tile, as on the app icon.
let isAccent = (row == 0 && column == 1)
let path = NSBezierPath(roundedRect: rect.insetBy(dx: 0.6, dy: 0.6), xRadius: 2, yRadius: 2)
if isAccent, let fill {
fill.setFill()
path.fill()
} else {
outline.setStroke()
path.lineWidth = 1.3
path.stroke()
}
}
}
return true
}
image.isTemplate = false
image.accessibilityDescription = "ProdDash — \(state.label)"
return image
}
}
@@ -0,0 +1,95 @@
import AppKit
import Foundation
import ServiceManagement
import UserNotifications
/// Notifications about the server, for a booth machine nobody is watching.
/// Quiet by design: only a stop nobody asked for is worth interrupting anyone.
final class Notifier {
static let shared = Notifier()
private var authorized = false
private var asked = false
private var available: Bool { Bundle.main.bundleIdentifier != nil }
private init() {}
/// Ask once, when the operator turns the setting on.
func requestAuthorization(_ completion: ((Bool) -> Void)? = nil) {
guard available else { return completion?(false) ?? () }
asked = true
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
DispatchQueue.main.async {
self.authorized = granted
completion?(granted)
}
}
}
func refreshStatus(_ completion: @escaping (UNAuthorizationStatus) -> Void) {
guard available else { return completion(.denied) }
UNUserNotificationCenter.current().getNotificationSettings { settings in
DispatchQueue.main.async {
self.authorized = settings.authorizationStatus == .authorized
completion(settings.authorizationStatus)
}
}
}
/// Posting never asks for permission: a prompt that appears because
/// something just failed, unattended, is the worst possible moment for it.
/// It is asked for from the Permissions tab; until then this does nothing.
func post(title: String, body: String) {
guard available, authorized else { return }
let content = UNMutableNotificationContent()
content.title = title
content.body = body
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
}
/// "Open ProdDash at login", through the modern (macOS 13+) login-item API.
/// The operator may still have to approve it in System Settings — hence
/// `needsApproval`, which the window surfaces rather than silently failing.
enum LoginItem {
static var isEnabled: Bool { SMAppService.mainApp.status == .enabled }
static var needsApproval: Bool { SMAppService.mainApp.status == .requiresApproval }
@discardableResult
static func set(_ enabled: Bool) -> String? {
do {
if enabled {
if SMAppService.mainApp.status != .enabled { try SMAppService.mainApp.register() }
} else {
if SMAppService.mainApp.status == .enabled { try SMAppService.mainApp.unregister() }
}
return nil
} catch {
return error.localizedDescription
}
}
static func openSystemSettings() {
SMAppService.openSystemSettingsLoginItems()
}
}
enum SystemSettings {
/// Deep links into the Privacy & Security panes. Opening the app itself is
/// the fallback when a pane identifier stops working on a later macOS.
static func open(_ pane: String) {
guard let url = URL(string: pane) else { return }
if !NSWorkspace.shared.open(url) {
NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Settings.app"))
}
}
static let microphone = "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
static let localNetwork = "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork"
static let notifications = "x-apple.systempreferences:com.apple.Notifications-Settings.extension"
static let privacy = "x-apple.systempreferences:com.apple.preference.security?Privacy"
}
+9
View File
@@ -0,0 +1,9 @@
import AppKit
// ProdDash Launcher — a menu-bar app, so no Dock icon and no app menu
// (LSUIElement in Info.plist says the same thing to Launch Services).
let delegate = AppDelegate()
let application = NSApplication.shared
application.delegate = delegate
application.setActivationPolicy(.accessory)
application.run()
+222
View File
@@ -0,0 +1,222 @@
#!/bin/zsh
# Build ProdDash.app — the whole application: launcher, server and its own
# Node runtime, with nothing to install first.
#
# ./build.sh build/ProdDash.app, self-contained
# ./build.sh --dmg …and build/ProdDash-<version>-arm64.dmg
# ./build.sh --install …and copy it to /Applications
# ./build.sh --run …and launch it
# ./build.sh --notarize with --dmg: notarise and staple (needs a
# Developer ID and a notarytool keychain profile)
# ./build.sh --dev launcher only — no Node, no server payload, so
# it runs against the checkout. Seconds, not
# minutes, while working on the launcher itself.
#
# Signing: a "Developer ID Application" certificate is used when one is
# installed (override with CODESIGN_IDENTITY). Without one the app is signed
# ad-hoc, which is fine on your own Macs but makes another Mac ask before it
# will open the app.
#
# Needs the Xcode command line tools. Everything else it fetches or builds.
set -e -u
cd "$(dirname "$0")"
APP_NAME="ProdDash"
BUNDLE_ID="org.waterschurch.proddash"
LAUNCHER_VERSION="1.0.0"
# The Node the app ships. Bump it here; the download is checksum-verified
# against nodejs.org's own SHASUMS256.txt and cached in .cache/.
NODE_VERSION="v24.21.0"
ARCH="arm64"
DEPLOYMENT_TARGET="13.0"
REPO_ROOT="../.."
OUT_DIR="build"
CACHE_DIR=".cache"
APP="$OUT_DIR/$APP_NAME.app"
CONTENTS="$APP/Contents"
PAYLOAD="$CONTENTS/Resources/app"
DEV=0
INSTALL=0
RUN=0
MAKE_DMG=0
NOTARIZE=0
while [[ $# -gt 0 ]]; do
case "$1" in
--dev) DEV=1 ;;
--install) INSTALL=1 ;;
--run) RUN=1 ;;
--dmg) MAKE_DMG=1 ;;
--notarize) NOTARIZE=1; MAKE_DMG=1 ;;
-h|--help) sed -n '2,22p' "$0"; exit 0 ;;
*) echo "build.sh: unknown option $1" >&2; exit 2 ;;
esac
shift
done
command -v swiftc >/dev/null || {
echo "build.sh: swiftc not found — install the Xcode command line tools:" >&2
echo " xcode-select --install" >&2
exit 1
}
# The product's version is ProdDash's own; the launcher keeps its own number
# for when something is wrong with the launcher rather than the dashboard.
VERSION=$(sed -n 's/.*"version" *: *"\([^"]*\)".*/\1/p' "$REPO_ROOT/package.json" | head -1)
[[ -n "$VERSION" ]] || { echo "build.sh: can't read the version from package.json" >&2; exit 1; }
echo "ProdDash $VERSION (launcher $LAUNCHER_VERSION) — building for $ARCH"
rm -rf "$OUT_DIR"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
# ── the launcher ─────────────────────────────────────────────────────────
echo " compiling the launcher"
swiftc -O -whole-module-optimization \
-target "$ARCH-apple-macos$DEPLOYMENT_TARGET" \
-module-name ProdDashLauncher \
-o "$CONTENTS/MacOS/$APP_NAME" \
Sources/*.swift
cp Resources/Info.plist "$CONTENTS/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$CONTENTS/Info.plist" >/dev/null
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$CONTENTS/Info.plist" >/dev/null
/usr/libexec/PlistBuddy -c "Set :ProdDashLauncherVersion $LAUNCHER_VERSION" "$CONTENTS/Info.plist" >/dev/null
printf 'APPL????' > "$CONTENTS/PkgInfo"
SOURCE_ICON="$REPO_ROOT/public/icons/icon-512.png"
if [[ -f "$SOURCE_ICON" ]]; then
echo " building the icon"
ICONSET="$OUT_DIR/AppIcon.iconset"
mkdir -p "$ICONSET"
for size in 16 32 64 128 256 512 1024; do
sips -z $size $size "$SOURCE_ICON" --out "$ICONSET/icon_${size}x${size}.png" >/dev/null 2>&1
done
mv "$ICONSET/icon_32x32.png" "$ICONSET/icon_16x16@2x.png"
cp "$ICONSET/icon_64x64.png" "$ICONSET/icon_32x32@2x.png"
mv "$ICONSET/icon_64x64.png" "$ICONSET/icon_32x32.png"
cp "$ICONSET/icon_256x256.png" "$ICONSET/icon_128x128@2x.png"
cp "$ICONSET/icon_512x512.png" "$ICONSET/icon_256x256@2x.png"
mv "$ICONSET/icon_1024x1024.png" "$ICONSET/icon_512x512@2x.png"
iconutil -c icns "$ICONSET" -o "$CONTENTS/Resources/AppIcon.icns"
rm -rf "$ICONSET"
fi
# ── the server, and the Node that runs it ────────────────────────────────
if [[ $DEV -eq 0 ]]; then
TARBALL="node-$NODE_VERSION-darwin-$ARCH.tar.gz"
mkdir -p "$CACHE_DIR"
if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then
echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)"
curl -fsSL --retry 3 -o "$CACHE_DIR/$TARBALL.part" "https://nodejs.org/dist/$NODE_VERSION/$TARBALL"
mv "$CACHE_DIR/$TARBALL.part" "$CACHE_DIR/$TARBALL"
fi
SUMS="$CACHE_DIR/SHASUMS256-$NODE_VERSION.txt"
[[ -f "$SUMS" ]] || curl -fsSL --retry 3 -o "$SUMS" "https://nodejs.org/dist/$NODE_VERSION/SHASUMS256.txt"
# This binary is about to be signed with your name on it — check it every build.
WANT=$(awk -v f="$TARBALL" '$2 == f {print $1}' "$SUMS")
GOT=$(shasum -a 256 "$CACHE_DIR/$TARBALL" | awk '{print $1}')
if [[ -z "$WANT" || "$WANT" != "$GOT" ]]; then
echo "build.sh: $TARBALL does not match nodejs.org's checksum — refusing to ship it" >&2
exit 1
fi
echo " embedding Node $NODE_VERSION"
mkdir -p "$CONTENTS/Resources/node/bin"
tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \
"node-$NODE_VERSION-darwin-$ARCH/bin/node"
echo " embedding ProdDash $VERSION"
mkdir -p "$PAYLOAD/config"
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$PAYLOAD/"
cp -R "$REPO_ROOT/public" "$REPO_ROOT/modules" "$PAYLOAD/"
# Only the checked-in defaults: config/ also holds this machine's own
# modules.json and layouts when ProdDash has ever run from the checkout.
cp "$REPO_ROOT/config/proddash.json" "$PAYLOAD/config/"
find "$PAYLOAD" -name '.DS_Store' -delete
# A module's native helper is built here, so the Mac running ProdDash needs
# no compiler: <name>.swift beside a module's files becomes <name>.
for source in "$PAYLOAD"/modules/*/*.swift; do
[[ -e "$source" ]] || continue
helper="${source%.swift}"
echo " compiling $(basename "$(dirname "$source")")/$(basename "$helper")"
swiftc -O -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$helper" "$source"
done
fi
# ── signing ──────────────────────────────────────────────────────────────
IDENTITY="${CODESIGN_IDENTITY:-}"
if [[ -z "$IDENTITY" ]]; then
IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
fi
if [[ -n "$IDENTITY" ]]; then
echo " signing as $IDENTITY"
SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp)
else
echo " signing ad-hoc (no Developer ID installed — other Macs will ask before opening it)"
IDENTITY="-"
SIGN=(--force --sign -)
fi
# Nested code first, outermost last, or the outer signature seals a lie.
if [[ -f "$CONTENTS/Resources/node/bin/node" ]]; then
codesign "${SIGN[@]}" --entitlements Resources/node.entitlements "$CONTENTS/Resources/node/bin/node"
fi
if [[ $DEV -eq 0 ]]; then
for helper in "$PAYLOAD"/modules/*/*; do
# A compiled helper is the extensionless executable beside a module's
# sources — test the name, not the path, which has dots all over it.
name=$(basename "$helper")
[[ -f "$helper" && -x "$helper" && "$name" != *.* ]] || continue
echo " signing $name"
codesign "${SIGN[@]}" --entitlements Resources/helper.entitlements "$helper"
done
fi
codesign "${SIGN[@]}" --entitlements Resources/ProdDash.entitlements --identifier "$BUNDLE_ID" "$APP"
codesign --verify --deep --strict "$APP" && echo " signature verifies"
SIZE=$(du -sh "$APP" | awk '{print $1}')
echo "built $APP ($SIZE)"
# ── disk image ───────────────────────────────────────────────────────────
if [[ $MAKE_DMG -eq 1 ]]; then
DMG="$OUT_DIR/$APP_NAME-$VERSION-$ARCH.dmg"
STAGE="$OUT_DIR/dmg"
rm -rf "$STAGE"
mkdir -p "$STAGE"
cp -R "$APP" "$STAGE/"
ln -s /Applications "$STAGE/Applications" # drag-to-install, the usual way
hdiutil create -volname "$APP_NAME $VERSION" -srcfolder "$STAGE" \
-ov -format UDZO -quiet "$DMG"
rm -rf "$STAGE"
[[ "$IDENTITY" == "-" ]] || codesign --force --sign "$IDENTITY" --timestamp "$DMG"
echo "built $DMG ($(du -sh "$DMG" | awk '{print $1}'))"
if [[ $NOTARIZE -eq 1 ]]; then
if [[ "$IDENTITY" == "-" ]]; then
echo "build.sh: can't notarise an ad-hoc signed app — a Developer ID is required" >&2
exit 1
fi
PROFILE="${NOTARY_PROFILE:-proddash}"
echo " notarising as keychain profile \"$PROFILE\" (a few minutes)"
xcrun notarytool submit "$DMG" --keychain-profile "$PROFILE" --wait
xcrun stapler staple "$DMG"
echo " notarised and stapled — this opens anywhere with no warning"
fi
fi
if [[ $INSTALL -eq 1 ]]; then
DEST="/Applications/$APP_NAME.app"
osascript -e "tell application \"$APP_NAME\" to quit" >/dev/null 2>&1 || true
sleep 1
rm -rf "$DEST"
cp -R "$APP" "$DEST"
echo "installed $DEST"
APP="$DEST"
fi
[[ $RUN -eq 1 ]] && open "$APP"
exit 0
+3 -4
View File
@@ -1,9 +1,8 @@
{
"id": "clock",
"name": "Clock",
"version": "1.0.0",
"version": "1.0.1",
"proddash": ">=1.1.0",
"description": "Big booth clock — the smallest possible ProdDash module",
"client": "client.js",
"style": "style.css",
"minSize": { "w": 2, "h": 1 },
@@ -18,7 +17,7 @@
{ "value": "12h", "label": "12-hour" }
]
},
"showSeconds": { "type": "boolean", "label": "Show seconds", "default": true },
"showDate": { "type": "boolean", "label": "Show date", "default": false }
"showSeconds": { "type": "boolean", "label": "Seconds", "default": true },
"showDate": { "type": "boolean", "label": "Date", "default": false }
}
}
+67 -23
View File
@@ -4,10 +4,16 @@
ProPresenter module's live item; this tile only renders the plan it is
streamed. What is shown is this tile's own choice (gear menu →
moduleApi.instanceSettings: show/hide checklist, top-bar template,
colors, text size); /admin only holds the connection settings. */
colors, item-note categories); the text size is the title bar's A− / A+
(saved with the tile, 14 px until someone presses one); /admin only holds
the connection settings. */
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
export default function create({ root, moduleApi }) {
let state = null;
let skew = 0; // server clock − local clock, ms
@@ -53,7 +59,7 @@ export default function create({ root, moduleApi }) {
const refreshBtn = moduleApi.header.addButton({
icon: REFRESH_SVG,
title: 'Re-read the plan from Planning Center now',
title: 'Refresh',
onClick() {
refreshBtn.classList.add('active');
moduleApi.fetch('/refresh', { method: 'POST' })
@@ -67,8 +73,8 @@ export default function create({ root, moduleApi }) {
moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
applyPrefs();
}
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-1) });
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(1) });
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-1) });
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(1) });
/* ── formatting ─────────────────────────────────────────────────── */
@@ -87,6 +93,16 @@ export default function create({ root, moduleApi }) {
return `${sign}${m}:${String(sec).padStart(2, '0')}`;
}
/** "Starts in": under a day like fmtDur; a day or more reads "2 Days, 05:12:44". */
function fmtStartsIn(totalSec) {
const s = Math.max(0, Math.round(totalSec));
const days = Math.floor(s / 86400);
if (!days) return fmtDur(s);
const rest = s % 86400;
const pad = (v) => String(v).padStart(2, '0');
return `${days} ${days === 1 ? 'Day' : 'Days'}, ${pad(Math.floor(rest / 3600))}:${pad(Math.floor((rest % 3600) / 60))}:${pad(rest % 60)}`;
}
function fmtMinutes(totalSec) {
const m = Math.round(totalSec / 60);
if (m < 60) return `${m} min`;
@@ -121,10 +137,19 @@ export default function create({ root, moduleApi }) {
});
}
/**
* Which note categories this tile shows. Unset (never chosen in the gear
* window) means every category, including ones Planning Center gains
* later; an array is exactly those, by name. A layout saved before the
* categories were a list holds a comma-separated string — read as that
* list, blank as unset.
*/
function noteFilter() {
const raw = String(cfg().noteCategories || '');
const wanted = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
return wanted.length ? (note) => wanted.includes(String(note.category || '').toLowerCase()) : () => true;
const raw = cfg().noteCategories;
const names = Array.isArray(raw) ? raw : String(raw ?? '').split(',');
const wanted = new Set(names.map((s) => String(s).trim().toLowerCase()).filter(Boolean));
if (!Array.isArray(raw) && !wanted.size) return () => true;
return (note) => wanted.has(String(note.category || '').trim().toLowerCase());
}
/* ── status dot ─────────────────────────────────────────────────── */
@@ -209,36 +234,55 @@ export default function create({ root, moduleApi }) {
renderClock();
}
/**
* The header clock follows the service the server says owns the clock
* (state.service: idle → running → stopped, by schedule or by the admin's
* ProPresenter document cues): a countdown before it, the running time
* while it runs (with ahead/behind when following ProPresenter), the final
* time once it has ended.
*/
function renderClock() {
const plan = state?.plan;
const live = state?.live || {};
const svc = state?.service || {};
clockLabelEl.textContent = '';
clockValueEl.textContent = '';
clockValueEl.className = 'pp-clock-value';
if (!plan || headEl.hidden) return;
const now = serverNow();
if (on('showRunningClock') && live.following && live.serviceStartedAt) {
const startedAt = svc.startedAt || live.serviceStartedAt || 0;
if (on('showRunningClock') && svc.phase === 'running' && startedAt) {
clockLabelEl.textContent = 'Running';
clockValueEl.textContent = fmtDur((now - live.serviceStartedAt) / 1000);
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take.
const done = state.items.filter((it) => live.history?.[it.id]?.endedAt);
const planned = done.reduce((sum, it) => sum + (it.length || 0), 0);
const actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0);
const drift = actual - planned;
if (planned && Math.abs(drift) >= 30) {
clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`;
clockValueEl.classList.add(drift > 0 ? 'is-behind' : 'is-ahead');
clockValueEl.textContent = fmtDur((now - startedAt) / 1000);
if (live.following) {
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take.
const done = state.items.filter((it) => live.history?.[it.id]?.endedAt);
const planned = done.reduce((sum, it) => sum + (it.length || 0), 0);
const actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0);
const drift = actual - planned;
if (planned && Math.abs(drift) >= 30) {
clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`;
clockValueEl.classList.add(drift > 0 ? 'is-behind' : 'is-ahead');
}
}
return;
}
if (on('showCountdown') && plan.serviceStartsAt && plan.serviceStartsAt > now) {
clockLabelEl.textContent = 'Starts in';
clockValueEl.textContent = fmtDur((plan.serviceStartsAt - now) / 1000);
if (on('showRunningClock') && svc.phase === 'stopped' && startedAt && svc.endedAt) {
clockLabelEl.textContent = 'Ended';
clockValueEl.textContent = fmtDur((svc.endedAt - startedAt) / 1000);
return;
}
if (on('showRunningClock') && plan.serviceStartsAt) {
clockLabelEl.textContent = 'Since start';
clockValueEl.textContent = fmtDur((now - plan.serviceStartsAt) / 1000);
const startsAt = svc.startsAt || plan.serviceStartsAt || 0;
if (on('showCountdown') && startsAt > now) {
clockLabelEl.textContent = 'Starts in';
clockValueEl.textContent = fmtStartsIn((startsAt - now) / 1000);
return;
}
if (on('showRunningClock') && startsAt) {
// Past the scheduled start and not started: waiting for the admin's
// start document to go live in ProPresenter (or its failsafe).
clockLabelEl.textContent = 'Scheduled';
clockValueEl.textContent = fmtClock(startsAt);
}
}
+85
View File
@@ -0,0 +1,85 @@
'use strict';
/**
* Title matching for the PCO Plan module — the one answer to "is this
* ProPresenter document that Planning Center thing?", used twice:
*
* - mapping the document ProPresenter has live to a plan item (the
* highlight and the item runtimes), and
* - recognising the documents an admin picked as the service start / end
* cues (see service-timing.js).
*
* A ProPresenter name and a Planning Center title rarely agree letter for
* letter ("This Is The Day - [ Full ]" vs "This Is The Day"), so both sides
* are tidied the way propresenter-core.cleanItemName does (tags in brackets,
* leading date codes and dash separators dropped), lower-cased, stripped of
* punctuation and compared:
*
* 1.00 identical after tidying "Post-Service Loop" ≈ "Post Service Loop"
* 0.85 one contains the other "Welcome" ≈ "Welcome & Announcements"
* Dice 2·shared words ÷ all words "Pre Service Loop" vs "Post Service Loop" → 0.67
*
* Filler words ("the", "of", "and"…) are ignored. The thresholds:
*
* MATCH_THRESHOLD 0.6 plan items — two of three words is enough, because
* the item that scores best *and* is next in the
* plan wins, so a near miss lands on the right row.
* TRIGGER_THRESHOLD 0.75 service cues — a cue starts or freezes the service
* clock, so one wrong word must not fire it: the
* pre-service loop (0.67 against "Post Service Loop")
* stays a non-match, while a renamed
* "Post-Service Loop", a tagged "Post Service Loop
* [Full]" (1.0) or a shortened "Post Service" (0.85)
* still trigger.
*/
const MATCH_THRESHOLD = 0.6;
const TRIGGER_THRESHOLD = 0.75;
/** Same title tidying as propresenter-core.cleanItemName (drops [tags], date codes). */
function cleanTitle(raw) {
const original = String(raw || '').trim();
let s = original;
s = s.replace(/[[({][^\])}]*[\])}]/g, ' ');
s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, '');
s = s.replace(/\s[-–—]+\s/g, ' ');
s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, '');
s = s.replace(/\s{2,}/g, ' ').trim();
return s || original;
}
function normalize(raw) {
return cleanTitle(raw)
.toLowerCase()
.replace(/&/g, ' and ')
.replace(/[’'`]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
const STOPWORDS = new Set(['the', 'a', 'an', 'of', 'and', 'to', 'in', 'is', 'my', 'your', 'our']);
function tokens(norm) {
return new Set(norm.split(' ').filter((t) => t && !STOPWORDS.has(t)));
}
/** 0..1 similarity between two titles. */
function similarity(a, b) {
const na = normalize(a);
const nb = normalize(b);
if (!na || !nb) return 0;
if (na === nb) return 1;
if (na.length >= 4 && nb.length >= 4 && (na.includes(nb) || nb.includes(na))) return 0.85;
const ta = tokens(na);
const tb = tokens(nb);
if (!ta.size || !tb.size) return 0;
let common = 0;
for (const t of ta) if (tb.has(t)) common += 1;
return (2 * common) / (ta.size + tb.size); // Dice coefficient
}
/** Does a live ProPresenter document count as the cue document an admin picked? */
function isCue(liveName, cueDocument) {
return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD;
}
module.exports = { MATCH_THRESHOLD, TRIGGER_THRESHOLD, cleanTitle, normalize, similarity, isCue };
+319 -56
View File
@@ -1,24 +1,36 @@
{
"id": "pco-plan",
"name": "PCO Plan",
"version": "1.3.0",
"proddash": ">=1.3.0",
"description": "Order of service from Planning Center Services, following ProPresenter live",
"version": "1.5.0",
"proddash": ">=1.4.0",
"description": "Order of service from Planning Center Services",
"provides": [
"timers"
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": { "w": 3, "h": 3 },
"defaultSize": { "w": 5, "h": 6 },
"minSize": {
"w": 3,
"h": 3
},
"defaultSize": {
"w": 5,
"h": 6
},
"configGroups": {
"Planning Center credentials": {
"collapsed": "whenSet",
"help": "Create a Personal Access Token at api.planningcenteronline.com (Application ID + Secret), or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers."
"help": "Create a Personal Access Token at api.planningcenteronline.com and paste its Client ID + Secret here, or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers."
},
"Service timing": {
"help": "Each service in the plan takes its turn owning the service clock: the next one takes over Pre-roll minutes before its scheduled start, and the elapsed clock, item timing and countdown start afresh for it. A service starts at its scheduled time — or, with document triggers on, the moment the chosen Start document goes live in ProPresenter (falling back to the schedule after the start failsafe) — and ends when the End document goes live, or when the next service takes over. The document lists hold what ProPresenter has shown or listed since ProdDash started plus what is saved here; names are matched loosely, so a renamed \"Post-Service Loop\" still counts."
}
},
"configSchema": {
"appId": {
"type": "string",
"label": "Application ID",
"label": "Client ID",
"default": "",
"group": "Planning Center credentials"
},
@@ -30,7 +42,7 @@
},
"accessToken": {
"type": "password",
"label": "OAuth 2.0 access token (instead of Application ID + Secret)",
"label": "OAuth 2.0 access token (instead of Client ID + Secret)",
"default": "",
"group": "Planning Center credentials"
},
@@ -38,27 +50,86 @@
"type": "select",
"label": "Service type",
"default": "",
"optionsRoute": "/service-types",
"help": "Your Planning Center Services folders appear as groups in this list; pick the service type whose plans the tiles follow. Save the credentials first if the list is empty."
"optionsRoute": "/service-types"
},
"planId": {
"type": "select",
"label": "Plan",
"default": "",
"options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }],
"options": [
{
"value": "",
"label": "Automatic — today's plan, else the next upcoming"
}
],
"optionsRoute": "/plans",
"optionsDependsOn": "serviceTypeId",
"help": "Leave on Automatic for services. Pin a specific plan for rehearsals or to review a past service."
"optionsDependsOn": "serviceTypeId"
},
"pollSeconds": {
"type": "number",
"label": "Re-read the plan every N seconds",
"label": "Refresh Interval (s)",
"default": 30
},
"followProPresenter": {
"type": "switch",
"label": "Follow the ProPresenter module's live item to highlight the current plan item and time it",
"label": "Follow ProPresenter Module",
"default": true
},
"preRollMinutes": {
"type": "number",
"label": "Pre-roll (min)",
"default": 30,
"group": "Service timing",
"help": "How long before a service's scheduled start the clock, item timing and countdown switch to it."
},
"useDocumentTriggers": {
"type": "switch",
"label": "ProPresenter document triggers",
"default": false,
"group": "Service timing"
},
"serviceStartTrigger": {
"type": "select",
"label": "Service start",
"default": "schedule",
"group": "Service timing",
"showWhen": "useDocumentTriggers",
"options": [
{
"value": "schedule",
"label": "Scheduled service time"
},
{
"value": "document",
"label": "ProPresenter document"
}
]
},
"serviceStartDocument": {
"type": "select",
"label": "Start document",
"default": "",
"group": "Service timing",
"showWhen": "useDocumentTriggers",
"optionsRoute": "/documents",
"help": "The document that goes live as the service begins."
},
"startGraceMinutes": {
"type": "number",
"label": "Start failsafe (min)",
"default": 10,
"group": "Service timing",
"showWhen": "useDocumentTriggers",
"help": "Without the start document this long after the scheduled start, the service counts from the scheduled start anyway."
},
"serviceEndDocument": {
"type": "select",
"label": "End document",
"default": "",
"group": "Service timing",
"showWhen": "useDocumentTriggers",
"optionsRoute": "/documents",
"help": "The document that goes live as the service ends; it freezes the service clock."
}
},
"instanceGroups": {
@@ -69,56 +140,248 @@
"collapsed": false,
"columns": 2
},
"Show / hide": {
"Item notes": {
"toggle": "showNotes"
},
"Show": {
"collapsed": true,
"columns": 3,
"help": "Untick anything this tile should not show."
"columns": 2
}
},
"instanceSchema": {
"textSize": { "type": "number", "label": "Text size (px)", "default": 14 },
"autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true },
"noteCategories": {
"type": "string",
"label": "Item note categories to show (comma-separated, blank = all)",
"default": ""
"autoScroll": {
"type": "boolean",
"label": "Auto-scroll",
"default": true
},
"headerTemplate": {
"type": "string",
"type": "template",
"label": "Top bar text",
"default": "{series} • {part} | {serviceType} • {date}",
"group": "Top bar",
"help": "Placeholders: {series} series name · {part} plan title / part number · {serviceType} · {date} · {shortDate} · {time} service start time · {length} planned length · {items} item count. Use • between items (an item whose placeholders are empty or hidden is dropped) and | to start a second line."
"variables": [
{
"value": "{series}",
"label": "Series"
},
{
"value": "{part}",
"label": "Part / plan title"
},
{
"value": "{serviceType}",
"label": "Service type"
},
{
"value": "{date}",
"label": "Date"
},
{
"value": "{shortDate}",
"label": "Short date"
},
{
"value": "{time}",
"label": "Service time"
},
{
"value": "{length}",
"label": "Planned length"
},
{
"value": "{items}",
"label": "Item count"
}
],
"help": "Use • between items and | to start a second line."
},
"keyColor": { "type": "color", "label": "Song key", "default": "#4ea1ff", "group": "Colors" },
"headerColor": { "type": "color", "label": "Section headers", "default": "#2ee59a", "group": "Colors" },
"currentColor": { "type": "color", "label": "Current item", "default": "#2ee59a", "group": "Colors" },
"headerGradient": { "type": "boolean", "label": "Fade header color to the right", "default": true, "group": "Colors" },
"tintByType": { "type": "boolean", "label": "Tint items by type (song / media / other)", "default": false, "group": "Colors" },
"showTopBar": { "type": "boolean", "label": "Top bar", "default": true, "group": "Show / hide" },
"showSeries": { "type": "boolean", "label": "Series name", "default": true, "group": "Show / hide" },
"showPart": { "type": "boolean", "label": "Part # / plan title", "default": true, "group": "Show / hide" },
"showServiceType": { "type": "boolean", "label": "Service type", "default": true, "group": "Show / hide" },
"showDate": { "type": "boolean", "label": "Plan date", "default": true, "group": "Show / hide" },
"showServiceTime": { "type": "boolean", "label": "Service time", "default": true, "group": "Show / hide" },
"showTotalLength": { "type": "boolean", "label": "Planned total length", "default": true, "group": "Show / hide" },
"showRunningClock": { "type": "boolean", "label": "Running clock (ahead / behind)", "default": true, "group": "Show / hide" },
"showCountdown": { "type": "boolean", "label": "Countdown to service start", "default": true, "group": "Show / hide" },
"showHeaders": { "type": "boolean", "label": "Section headers", "default": true, "group": "Show / hide" },
"showItemNumbers": { "type": "boolean", "label": "Item numbers", "default": false, "group": "Show / hide" },
"showStartTimes": { "type": "boolean", "label": "Scheduled start times", "default": true, "group": "Show / hide" },
"showLength": { "type": "boolean", "label": "Item length", "default": true, "group": "Show / hide" },
"showItemType": { "type": "boolean", "label": "Item type badge", "default": true, "group": "Show / hide" },
"showKey": { "type": "boolean", "label": "Song key", "default": true, "group": "Show / hide" },
"showArrangement": { "type": "boolean", "label": "Song arrangement", "default": false, "group": "Show / hide" },
"showSongAuthor": { "type": "boolean", "label": "Song author / CCLI", "default": false, "group": "Show / hide" },
"showDescription": { "type": "boolean", "label": "Item description", "default": true, "group": "Show / hide" },
"showNotes": { "type": "boolean", "label": "Item notes", "default": true, "group": "Show / hide" },
"showPreService": { "type": "boolean", "label": "Pre-service items", "default": true, "group": "Show / hide" },
"showPostService": { "type": "boolean", "label": "Post-service items", "default": true, "group": "Show / hide" },
"showNowIndicator": { "type": "boolean", "label": "Now indicator", "default": true, "group": "Show / hide" },
"showItemRuntime": { "type": "boolean", "label": "Item runtime (elapsed vs planned)", "default": true, "group": "Show / hide" },
"dimCompleted": { "type": "boolean", "label": "Dim completed items", "default": true, "group": "Show / hide" },
"showLiveFooter": { "type": "boolean", "label": "ProPresenter match line", "default": true, "group": "Show / hide" }
"keyColor": {
"type": "color",
"label": "Song key",
"default": "#4ea1ff",
"group": "Colors"
},
"headerColor": {
"type": "color",
"label": "Section headers",
"default": "#2ee59a",
"group": "Colors"
},
"currentColor": {
"type": "color",
"label": "Current item",
"default": "#2ee59a",
"group": "Colors"
},
"headerGradient": {
"type": "boolean",
"label": "Header fade",
"default": true,
"group": "Colors"
},
"tintByType": {
"type": "boolean",
"label": "Tint by type",
"default": false,
"group": "Colors"
},
"showNotes": {
"type": "switch",
"label": "Item notes",
"default": true,
"group": "Item notes"
},
"noteCategories": {
"type": "multiselect",
"label": "Categories",
"group": "Item notes",
"optionsRoute": "/note-categories",
"columns": 3,
"allByDefault": true
},
"showTopBar": {
"type": "boolean",
"label": "Top bar",
"default": true,
"group": "Show"
},
"showSeries": {
"type": "boolean",
"label": "Series name",
"default": true,
"group": "Show"
},
"showPart": {
"type": "boolean",
"label": "Part / title",
"default": true,
"group": "Show"
},
"showServiceType": {
"type": "boolean",
"label": "Service type",
"default": true,
"group": "Show"
},
"showDate": {
"type": "boolean",
"label": "Plan date",
"default": true,
"group": "Show"
},
"showServiceTime": {
"type": "boolean",
"label": "Service time",
"default": true,
"group": "Show"
},
"showTotalLength": {
"type": "boolean",
"label": "Total length",
"default": true,
"group": "Show"
},
"showRunningClock": {
"type": "boolean",
"label": "Running clock",
"default": true,
"group": "Show"
},
"showCountdown": {
"type": "boolean",
"label": "Countdown",
"default": true,
"group": "Show"
},
"showHeaders": {
"type": "boolean",
"label": "Section headers",
"default": true,
"group": "Show"
},
"showItemNumbers": {
"type": "boolean",
"label": "Item numbers",
"default": false,
"group": "Show"
},
"showStartTimes": {
"type": "boolean",
"label": "Start times",
"default": true,
"group": "Show"
},
"showLength": {
"type": "boolean",
"label": "Item length",
"default": true,
"group": "Show"
},
"showItemType": {
"type": "boolean",
"label": "Type badge",
"default": true,
"group": "Show"
},
"showKey": {
"type": "boolean",
"label": "Song key",
"default": true,
"group": "Show"
},
"showArrangement": {
"type": "boolean",
"label": "Arrangement",
"default": false,
"group": "Show"
},
"showSongAuthor": {
"type": "boolean",
"label": "Author / CCLI",
"default": false,
"group": "Show"
},
"showDescription": {
"type": "boolean",
"label": "Item description",
"default": true,
"group": "Show"
},
"showPreService": {
"type": "boolean",
"label": "Pre-service",
"default": true,
"group": "Show"
},
"showPostService": {
"type": "boolean",
"label": "Post-service",
"default": true,
"group": "Show"
},
"showNowIndicator": {
"type": "boolean",
"label": "Now indicator",
"default": true,
"group": "Show"
},
"showItemRuntime": {
"type": "boolean",
"label": "Item runtime",
"default": true,
"group": "Show"
},
"dimCompleted": {
"type": "boolean",
"label": "Dim completed",
"default": true,
"group": "Show"
},
"showLiveFooter": {
"type": "boolean",
"label": "Match line",
"default": true,
"group": "Show"
}
}
}
+690 -120
View File
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
'use strict';
/**
* Service timing for the PCO Plan module — pure functions, no I/O, no
* timers. server.js feeds them the plan's service times, the documents
* ProPresenter puts live and the clock; service-timing.test.js calls them
* directly. Two questions are answered here:
*
* 1. Which of a plan's services owns the clock right now? → serviceWindows / selectService
* 2. When does that service count as started, and as ended? → advance
*
* ── Service windows ────────────────────────────────────────────────────
*
* A plan can have several services (plan_times of type "service": 9:00 and
* 11:00, say). Only ONE owns the service-elapsed clock, the current-item
* timing and the countdown at any moment, and moving into the next
* service's window resets all three. Each service's window runs from the
* end of the previous one to a boundary the next service's pre-roll sets:
*
* boundary(i → i+1) = min( start[i+1],
* max( start[i+1] − preRoll, endOf[i] ) )
*
* endOf[i] = the moment service i ended (its end cue fired), else its
* scheduled end (plan_time ends_at, else start + the planned
* length of the "during" items, else 90 min).
*
* In words: the next service takes over `preRoll` before its scheduled
* start (30 min by default; admin "Pre-roll"), but never before the current
* one has ended or was due to end, and never later than its own start. A
* service still running at the boundary is stopped there — that is the
* "next service's start" failsafe the end cue has. The first window opens at
* the beginning of time (before 9:00 the day belongs to the 9:00 service:
* countdown running, service idle); the last window never closes — the last
* service stays "stopped" until the plan itself changes (the next day's plan
* takes over through the plan picker). The last service also has a failsafe
* end: its scheduled end + LAST_SERVICE_OVERRUN_MS.
*
* ── Start and end of a service ─────────────────────────────────────────
*
* phase: idle → running → stopped, never backwards within one window.
*
* start, schedule mode (default) at the scheduled start; startedAt = it.
* start, document mode when the start document goes live
* (edge: it has to *become* live, from
* pre-roll before the scheduled start
* onwards — a rehearsal an hour earlier
* that shows the same document is not the
* service). Failsafe: if the document has
* not gone live by start + grace (admin
* "Start failsafe", 10 min default), the
* service starts anyway, counting from
* the scheduled start — the best estimate
* of when it really began.
* end, document when the end document goes live while
* the service is running (an end document
* live before the start — the previous
* service's loop — is ignored). Failsafe
* either way: the window boundary (see
* above), or, for the last service, its
* scheduled end + LAST_SERVICE_OVERRUN_MS.
*
* Cue documents are matched with matching.isCue (TRIGGER_THRESHOLD, see
* matching.js for the tolerance), so "Post-Service Loop" still ends the
* service when ProPresenter calls it "Post Service Loop".
*/
const { similarity, TRIGGER_THRESHOLD } = require('./matching');
const MINUTE = 60000;
/** How long before a service's scheduled start the timers switch to it (admin "Pre-roll"). */
const DEFAULT_PRE_ROLL_MS = 30 * MINUTE;
/** Document mode: start by the schedule anyway this long after the scheduled start (admin "Start failsafe"). */
const DEFAULT_START_GRACE_MS = 10 * MINUTE;
/** The last service of a plan is stopped this long after its scheduled end if nothing ended it. */
const LAST_SERVICE_OVERRUN_MS = 60 * MINUTE;
/** A service with no end time and no planned item lengths is assumed this long. */
const DEFAULT_SERVICE_LENGTH_MS = 90 * MINUTE;
/** The plan times that are services (every timed one when none is typed "service"), earliest first. */
function serviceTimes(planTimes) {
const list = Array.isArray(planTimes) ? planTimes.filter((t) => t && Number(t.startsAt) > 0) : [];
const services = list.filter((t) => t.timeType === 'service');
return (services.length ? services : list).slice().sort((a, b) => a.startsAt - b.startsAt);
}
/**
* The window each service owns (see the file comment).
*
* @param {Array} planTimes [{ id, name, timeType, startsAt, endsAt }] (ms epochs)
* @param {object} [opts]
* @param {number} [opts.preRollMs] how early the next service may take over (default 30 min)
* @param {number} [opts.plannedMs] planned length of the service — for services without ends_at
* @param {object} [opts.endedAt] { [serviceId]: ms } services already ended by their cue
* @returns {Array<{ id, name, index, count, startsAt, endsAt, windowFrom, windowUntil, cueFrom, failsafeEndAt, isLast }>}
*/
function serviceWindows(planTimes, opts = {}) {
const preRoll = Number.isFinite(opts.preRollMs) ? Math.max(0, opts.preRollMs) : DEFAULT_PRE_ROLL_MS;
const planned = Number(opts.plannedMs) > 0 ? Number(opts.plannedMs) : 0;
const ended = opts.endedAt && typeof opts.endedAt === 'object' ? opts.endedAt : {};
const services = serviceTimes(planTimes);
const windows = services.map((svc, i) => {
const scheduledEnd = svc.endsAt > svc.startsAt ? svc.endsAt : svc.startsAt + (planned || DEFAULT_SERVICE_LENGTH_MS);
const endOf = Number(ended[svc.id]) > 0 ? Number(ended[svc.id]) : scheduledEnd;
const next = services[i + 1];
const windowUntil = next ? Math.min(next.startsAt, Math.max(next.startsAt - preRoll, endOf)) : Infinity;
return {
id: String(svc.id),
name: String(svc.name || ''),
index: i,
count: services.length,
startsAt: svc.startsAt,
endsAt: scheduledEnd,
windowFrom: -Infinity, // filled in below
windowUntil,
cueFrom: svc.startsAt - preRoll, // a start document counts from here on
failsafeEndAt: next ? windowUntil : scheduledEnd + LAST_SERVICE_OVERRUN_MS,
isLast: !next,
};
});
for (let i = 1; i < windows.length; i += 1) windows[i].windowFrom = windows[i - 1].windowUntil;
return windows;
}
/** The window that holds `now` — null only when there are no services. */
function selectService(windows, now) {
if (!Array.isArray(windows) || !windows.length) return null;
return windows.find((w) => now >= w.windowFrom && now < w.windowUntil) || windows[windows.length - 1];
}
/** Timing state for a service that has just come into its window. */
function freshService(window) {
return {
id: window ? String(window.id) : '',
phase: window ? 'idle' : 'none',
startedAt: 0,
endedAt: 0,
startTrigger: '', // 'schedule' | 'document' | 'failsafe'
endTrigger: '', // 'document' | 'next-service' | 'plan-end'
cue: '', // the document that started / ended it
};
}
function fmtTime(ms) {
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
/**
* Move a service's timing on by a clock tick or by a document going live.
* Pure: returns a new state and a one-line description of what happened
* (for the log), or `changed: false`.
*
* @param {object} svc state from freshService(window) / a previous advance()
* @param {object} window from serviceWindows()
* @param {object} opts { startMode: 'schedule'|'document', startDocument, endDocument, startGraceMs }
* @param {number} now ms epoch
* @param {string} [cue] the document that just went live (omit on a plain tick)
* @returns {{ svc: object, changed: boolean, event: string }}
*/
function advance(svc, window, opts, now, cue) {
const o = opts || {};
const grace = Number.isFinite(o.startGraceMs) ? Math.max(0, o.startGraceMs) : DEFAULT_START_GRACE_MS;
const cueName = String(cue || '').trim();
const same = { svc, changed: false, event: '' };
if (!svc || !window || svc.id !== String(window.id)) return same;
const at = fmtTime(window.startsAt);
if (svc.phase === 'idle') {
const documentMode = o.startMode === 'document' && o.startDocument;
if (documentMode) {
if (cueName && now >= window.cueFrom) {
const score = similarity(cueName, o.startDocument);
if (score >= TRIGGER_THRESHOLD) {
return {
svc: { ...svc, phase: 'running', startedAt: now, startTrigger: 'document', cue: cueName },
changed: true,
event: `started at ${fmtTime(now)} — "${cueName}" went live (start document "${o.startDocument}", match ${score.toFixed(2)})`,
};
}
}
if (now >= window.startsAt + grace) {
return {
svc: { ...svc, phase: 'running', startedAt: window.startsAt, startTrigger: 'failsafe' },
changed: true,
event: `started (failsafe) — the start document "${o.startDocument}" did not go live within ${Math.round(grace / MINUTE)} min of ${at}; counting from ${at}`,
};
}
return same;
}
if (now >= window.startsAt) {
return {
svc: { ...svc, phase: 'running', startedAt: window.startsAt, startTrigger: 'schedule' },
changed: true,
event: `started — scheduled ${at}`,
};
}
return same;
}
if (svc.phase === 'running') {
if (o.endDocument && cueName) {
const score = similarity(cueName, o.endDocument);
if (score >= TRIGGER_THRESHOLD) {
return {
svc: { ...svc, phase: 'stopped', endedAt: now, endTrigger: 'document', cue: cueName },
changed: true,
event: `ended at ${fmtTime(now)} — "${cueName}" went live (end document "${o.endDocument}", match ${score.toFixed(2)})`,
};
}
}
if (now >= window.failsafeEndAt) {
const endedAt = Math.min(now, window.failsafeEndAt);
return {
svc: { ...svc, phase: 'stopped', endedAt, endTrigger: window.isLast ? 'plan-end' : 'next-service' },
changed: true,
event: window.isLast
? `stopped (failsafe) — ${Math.round(LAST_SERVICE_OVERRUN_MS / MINUTE)} min past its scheduled end ${fmtTime(window.endsAt)}`
: `stopped (failsafe) — the next service's window opened at ${fmtTime(endedAt)}`,
};
}
return same;
}
return same;
}
module.exports = {
MINUTE,
DEFAULT_PRE_ROLL_MS,
DEFAULT_START_GRACE_MS,
LAST_SERVICE_OVERRUN_MS,
DEFAULT_SERVICE_LENGTH_MS,
serviceTimes,
serviceWindows,
selectService,
freshService,
advance,
fmtTime,
};
+229
View File
@@ -0,0 +1,229 @@
'use strict';
/**
* Unit tests for the PCO Plan module's pure service-timing logic.
*
* node modules/pco-plan/service-timing.test.js
*
* Plain Node, no dependencies; exits non-zero on the first failure. Times are
* built for a two-service Sunday (9:00 and 11:00, 75 min each, 30 min
* pre-roll) so the expectations read like the day would.
*/
const assert = require('assert');
const { similarity, isCue, MATCH_THRESHOLD, TRIGGER_THRESHOLD } = require('./matching');
const {
MINUTE,
LAST_SERVICE_OVERRUN_MS,
serviceWindows,
selectService,
freshService,
advance,
} = require('./service-timing');
const DAY = new Date(2026, 8, 13, 0, 0, 0, 0).getTime(); // a local midnight
const at = (h, m = 0) => DAY + (h * 60 + m) * MINUTE;
const hm = (ms) => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const PLAN_TIMES = [
{ id: 'r', name: 'Rehearsal', timeType: 'rehearsal', startsAt: at(8, 0), endsAt: at(8, 45) },
{ id: 's1', name: 'First Service', timeType: 'service', startsAt: at(9, 0), endsAt: at(10, 15) },
{ id: 's2', name: 'Second Service', timeType: 'service', startsAt: at(11, 0), endsAt: at(12, 15) },
];
const PRE_ROLL = 30 * MINUTE;
const OPTS = { preRollMs: PRE_ROLL };
let passed = 0;
function test(name, fn) {
try {
fn();
passed += 1;
console.log(` ok ${name}`);
} catch (err) {
console.log(` FAIL ${name}\n ${err.message}`);
process.exitCode = 1;
throw err;
}
}
console.log('service windows');
test('rehearsal is not a service; windows are contiguous and ordered', () => {
const w = serviceWindows(PLAN_TIMES, OPTS);
assert.deepStrictEqual(w.map((x) => x.id), ['s1', 's2']);
assert.strictEqual(w[0].windowFrom, -Infinity);
assert.strictEqual(w[0].windowUntil, w[1].windowFrom);
assert.strictEqual(w[1].windowUntil, Infinity);
assert.strictEqual(w[0].count, 2);
});
test('the boundary is 30 min before the next start once the first was due to end', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
// 10:15 scheduled end, 11:00 − 30 = 10:30 → boundary 10:30
assert.strictEqual(hm(w1.windowUntil), hm(at(10, 30)));
assert.strictEqual(w1.failsafeEndAt, w1.windowUntil, 'first service failsafe-stops at the boundary');
});
test('a pre-roll larger than the gap waits for the scheduled end, never passes the next start', () => {
const [w1] = serviceWindows(PLAN_TIMES, { preRollMs: 60 * MINUTE });
assert.strictEqual(hm(w1.windowUntil), hm(at(10, 15)), 'max(10:00, 10:15)');
const [tight] = serviceWindows(
[
{ id: 'a', timeType: 'service', startsAt: at(9, 0), endsAt: at(10, 30) },
{ id: 'b', timeType: 'service', startsAt: at(10, 0), endsAt: at(11, 0) },
],
OPTS
);
assert.strictEqual(hm(tight.windowUntil), hm(at(10, 0)), 'capped at the next start');
});
test('an early actual end lets the next window open at pre-roll', () => {
const [w1] = serviceWindows(PLAN_TIMES, { preRollMs: 60 * MINUTE, endedAt: { s1: at(10, 5) } });
assert.strictEqual(hm(w1.windowUntil), hm(at(10, 5)), 'ended at 10:05 inside the 10:00 pre-roll → flips at 10:05');
const [w1b] = serviceWindows(PLAN_TIMES, { preRollMs: PRE_ROLL, endedAt: { s1: at(10, 5) } });
assert.strictEqual(hm(w1b.windowUntil), hm(at(10, 30)), 'ended at 10:05 before the 10:30 pre-roll → flips at 10:30');
});
test('a service without ends_at gets the planned length, else 90 min', () => {
const [w] = serviceWindows([{ id: 'x', timeType: 'service', startsAt: at(9, 0), endsAt: 0 }], { plannedMs: 70 * MINUTE });
assert.strictEqual(hm(w.endsAt), hm(at(10, 10)));
const [w90] = serviceWindows([{ id: 'x', timeType: 'service', startsAt: at(9, 0), endsAt: 0 }], {});
assert.strictEqual(hm(w90.endsAt), hm(at(10, 30)));
assert.strictEqual(w90.failsafeEndAt, w90.endsAt + LAST_SERVICE_OVERRUN_MS);
});
test('selectService: 8:50 → 9:00, 9:30 → 9:00, 10:55 → 11:00, 11:05 → 11:00', () => {
const w = serviceWindows(PLAN_TIMES, OPTS);
assert.strictEqual(selectService(w, at(8, 50)).id, 's1');
assert.strictEqual(selectService(w, at(9, 30)).id, 's1');
assert.strictEqual(selectService(w, at(10, 29)).id, 's1', 'still the first service until the 10:30 boundary');
assert.strictEqual(selectService(w, at(10, 55)).id, 's2');
assert.strictEqual(selectService(w, at(11, 5)).id, 's2');
assert.strictEqual(selectService(w, at(6, 0)).id, 's1', 'the morning belongs to the first service');
assert.strictEqual(selectService(w, at(15, 0)).id, 's2', 'the afternoon stays with the last service');
assert.strictEqual(selectService([], at(9, 0)), null);
});
console.log('service start');
const SCHEDULE = { startMode: 'schedule', startDocument: '', endDocument: '', startGraceMs: 10 * MINUTE };
const DOCUMENT = {
startMode: 'document',
startDocument: 'Welcome & Announcements',
endDocument: 'Post Service Loop',
startGraceMs: 10 * MINUTE,
};
test('schedule mode: idle before 9:00, running from 9:00 counting from 9:00; documents ignored', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
let s = freshService(w1);
assert.strictEqual(advance(s, w1, SCHEDULE, at(8, 59), 'Welcome').changed, false, 'a document is not a cue in schedule mode');
assert.strictEqual(advance(s, w1, SCHEDULE, at(8, 59)).changed, false);
const out = advance(s, w1, SCHEDULE, at(9, 0, 30));
assert.strictEqual(out.changed, true);
s = out.svc;
assert.strictEqual(s.phase, 'running');
assert.strictEqual(s.startedAt, at(9, 0), 'counts from the scheduled start, not the tick that noticed');
assert.strictEqual(s.startTrigger, 'schedule');
});
test('document mode: the start document going live starts the service at that moment', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
const s = freshService(w1);
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 0, 30)).changed, false, 'the scheduled time alone does not start it');
const out = advance(s, w1, DOCUMENT, at(8, 58), 'Welcome and Announcements [Full]');
assert.strictEqual(out.changed, true, 'a renamed / tagged start document still counts');
assert.strictEqual(out.svc.phase, 'running');
assert.strictEqual(out.svc.startedAt, at(8, 58), 'an early start is the real start');
assert.strictEqual(out.svc.startTrigger, 'document');
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 1), 'Post Service Loop').changed, false, 'the end document does not start an idle service');
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 1), 'Goodness of God').changed, false, 'an unrelated document does nothing');
});
test('document mode: the start document is only a cue from pre-roll before the start', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
const s = freshService(w1);
assert.strictEqual(advance(s, w1, DOCUMENT, at(8, 0), 'Welcome & Announcements').changed, false, 'rehearsal at 8:00 shows it — not the service');
assert.strictEqual(advance(s, w1, DOCUMENT, at(8, 30), 'Welcome & Announcements').changed, true, 'from 8:30 it counts');
});
test('document mode: failsafe at start + grace counts from the scheduled start', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
const s = freshService(w1);
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 9, 59)).changed, false);
const out = advance(s, w1, DOCUMENT, at(9, 10));
assert.strictEqual(out.changed, true);
assert.strictEqual(out.svc.phase, 'running');
assert.strictEqual(out.svc.startedAt, at(9, 0));
assert.strictEqual(out.svc.startTrigger, 'failsafe');
assert.match(out.event, /failsafe/);
});
test('document mode: a cue and the failsafe in the same tick — the cue wins', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
const out = advance(freshService(w1), w1, DOCUMENT, at(9, 12), 'Welcome & Announcements');
assert.strictEqual(out.svc.startTrigger, 'document');
assert.strictEqual(out.svc.startedAt, at(9, 12));
});
console.log('service end');
test('the end document freezes a running service; a second start document is ignored', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
let s = advance(freshService(w1), w1, DOCUMENT, at(9, 0), 'Welcome & Announcements').svc;
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 40), 'Welcome & Announcements').changed, false, 'shown again mid-service: nothing');
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 40), 'Pre Service Loop').changed, false, 'the PRE-service loop must not end it (0.67 < 0.75)');
const out = advance(s, w1, DOCUMENT, at(10, 12), 'Post-Service Loop');
assert.strictEqual(out.changed, true, 'a hyphenated end document still counts');
s = out.svc;
assert.strictEqual(s.phase, 'stopped');
assert.strictEqual(s.endedAt, at(10, 12));
assert.strictEqual(s.endTrigger, 'document');
assert.strictEqual(advance(s, w1, DOCUMENT, at(10, 20), 'Welcome & Announcements').changed, false, 'stopped stays stopped');
assert.strictEqual(advance(s, w1, DOCUMENT, at(10, 45)).changed, false);
});
test('failsafe: a service still running when the next window opens stops at the boundary', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
const s = advance(freshService(w1), w1, SCHEDULE, at(9, 0)).svc;
assert.strictEqual(advance(s, w1, SCHEDULE, at(10, 29)).changed, false, 'running past its 10:15 end is allowed');
const out = advance(s, w1, SCHEDULE, at(10, 31));
assert.strictEqual(out.changed, true);
assert.strictEqual(out.svc.phase, 'stopped');
assert.strictEqual(hm(out.svc.endedAt), hm(at(10, 30)), 'frozen at the boundary, not at the tick');
assert.strictEqual(out.svc.endTrigger, 'next-service');
});
test('failsafe: the last service stops an hour after its scheduled end', () => {
const [, w2] = serviceWindows(PLAN_TIMES, OPTS);
const s = advance(freshService(w2), w2, SCHEDULE, at(11, 0)).svc;
assert.strictEqual(advance(s, w2, SCHEDULE, at(13, 14)).changed, false);
const out = advance(s, w2, SCHEDULE, at(13, 15));
assert.strictEqual(out.changed, true);
assert.strictEqual(out.svc.phase, 'stopped');
assert.strictEqual(hm(out.svc.endedAt), hm(at(13, 15)));
assert.strictEqual(out.svc.endTrigger, 'plan-end');
});
test('the end document does not stop a service in the wrong window', () => {
const w = serviceWindows(PLAN_TIMES, OPTS);
const s1 = advance(freshService(w[0]), w[0], DOCUMENT, at(9, 0), 'Welcome & Announcements').svc;
assert.strictEqual(advance(s1, w[1], DOCUMENT, at(10, 40), 'Post Service Loop').changed, false, 'state and window must belong together');
});
console.log('matching');
test('plan items: 0.6 — two of three words; cues: 0.75 — pre/post loops stay apart', () => {
assert.strictEqual(MATCH_THRESHOLD, 0.6);
assert.strictEqual(TRIGGER_THRESHOLD, 0.75);
assert.strictEqual(similarity('Post-Service Loop', 'Post Service Loop'), 1);
assert.strictEqual(similarity('Post Service Loop [Full]', 'Post Service Loop'), 1);
assert.strictEqual(similarity('Post Service', 'Post Service Loop'), 0.85);
assert.ok(Math.abs(similarity('Pre Service Loop', 'Post Service Loop') - 2 / 3) < 0.001);
assert.strictEqual(isCue('Pre Service Loop', 'Post Service Loop'), false);
assert.strictEqual(isCue('Post-Service Loop', 'Post Service Loop'), true);
assert.strictEqual(isCue('Welcome', 'Welcome & Announcements'), true);
assert.strictEqual(isCue('Anything', ''), false);
});
console.log(`\n${passed} tests passed`);
+114 -140
View File
@@ -2,9 +2,14 @@
Ported from prodcom-listener/public/app.js into the ProdDash module
contract: everything lives inside the tile's root, per-tile state
(hidden channels, timestamps, text size) persists in instance settings,
and all network traffic goes through moduleApi (the module's server
proxy at /prodcom/*). */
(hidden channels, channel icons, timestamps, text size, flow direction)
persists in instance settings, and the data comes from the module's own
server, which keeps one connection to ProdCom for every dashboard: a
snapshot on connect (`state`), then each changed entry (`entry`). */
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
export default function create({ root, moduleApi }) {
/* ── per-instance state ─────────────────────────────────────────── */
@@ -12,10 +17,9 @@ export default function create({ root, moduleApi }) {
const channels = new Map(); // channelId -> {name, color}
const groups = new Map(); // groupId -> {name, channelIds}
const entryEls = new Map(); // entryId -> element
let pinnedToBottom = true;
let pinnedToLatest = true;
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
let stream = null; // moduleApi.sse handle
let bootTimer = null;
let stopped = false;
/* ── DOM skeleton ───────────────────────────────────────────────── */
@@ -40,11 +44,15 @@ export default function create({ root, moduleApi }) {
const CLOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
const JUMP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>';
const CLEAR_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></svg>';
const CHANNELS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16l-6 7.4V19l-4 2v-8.6z"/></svg>';
// arrow shows where new messages land: down = oldest first, up = newest first
const FLOW_DOWN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4v14"/><path d="m3 15 3 3 3-3"/><path d="M12 5h8"/><path d="M12 12h8"/><path d="M12 19h6"/></svg>';
const FLOW_UP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 20V6"/><path d="m3 9 3-3 3 3"/><path d="M12 5h8"/><path d="M12 12h8"/><path d="M12 19h6"/></svg>';
let channelMenuEl = null; // the open menu's element (filled on each open)
const channelsMenu = moduleApi.header.addMenu({
label: 'Channels ▾',
title: 'Choose visible channels',
icon: CHANNELS_SVG,
title: 'Channels',
build(menu) {
menu.classList.add('pt-menu');
channelMenuEl = menu;
@@ -52,11 +60,16 @@ export default function create({ root, moduleApi }) {
},
});
channelsMenu.button.classList.add('pt-channels-btn');
// the icon carries the label; a count rides alongside it while filtering
const channelCountEl = document.createElement('span');
channelCountEl.className = 'pt-count';
channelCountEl.hidden = true;
channelsMenu.button.appendChild(channelCountEl);
let timeBtn = null;
timeBtn = moduleApi.header.addButton({
icon: CLOCK_SVG,
title: 'Show / hide timestamps',
title: 'Timestamps',
onClick() {
moduleApi.saveInstanceSettings({ showTimes: !moduleApi.instanceSettings.showTimes });
applyInstanceSettings();
@@ -68,22 +81,33 @@ export default function create({ root, moduleApi }) {
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
applyInstanceSettings();
}
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) });
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(2) });
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
// icon and tooltip track the current direction — applyInstanceSettings sets both
const flowBtn = moduleApi.header.addButton({
icon: FLOW_DOWN_SVG,
onClick() {
moduleApi.saveInstanceSettings({ newestFirst: !moduleApi.instanceSettings.newestFirst });
applyInstanceSettings();
// the two directions have opposite "latest" ends — follow the flip
if (pinnedToLatest) scrollToLatest();
},
});
moduleApi.header.addButton({
icon: JUMP_SVG,
title: 'Jump to latest',
title: 'Latest',
onClick() {
pinnedToBottom = true;
pinnedToLatest = true;
jumpBtn.hidden = true;
scrollToBottom();
scrollToLatest();
},
});
const clearBtn = moduleApi.header.addButton({
icon: CLEAR_SVG,
title: 'Clear this tile (does not affect ProdCom)',
title: 'Clear',
onClick: clearView,
});
clearBtn.classList.add('pt-clear');
@@ -93,25 +117,18 @@ export default function create({ root, moduleApi }) {
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
wrap.style.setProperty('--pt-size', size + 'px');
wrap.classList.toggle('show-times', Boolean(s.showTimes));
wrap.classList.toggle('hide-avatars', s.showAvatars === false);
timeBtn?.classList.toggle('active', Boolean(s.showTimes));
const up = newestFirst();
wrap.classList.toggle('newest-first', up);
flowBtn.innerHTML = up ? FLOW_UP_SVG : FLOW_DOWN_SVG;
flowBtn.classList.toggle('active', up);
flowBtn.title = up ? 'Newest first' : 'Oldest first';
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
}
applyInstanceSettings();
/* ── channel filter (instance setting) ──────────────────────────── */
function channelFilter() {
return String(moduleApi.instanceSettings.channel || '').trim().toLowerCase();
}
/** Entry hidden by the hard per-tile filter (settings.channel)? */
function filteredOut(channelId, channelName) {
const want = channelFilter();
if (!want) return false;
const known = channels.get(channelId);
const name = ((known && known.name) || channelName || '').toLowerCase();
return name !== want && String(channelId).toLowerCase() !== want;
}
/* ── channel visibility toggles ─────────────────────────────────── */
function persistHidden() {
@@ -119,9 +136,7 @@ export default function create({ root, moduleApi }) {
}
function applyFilter(el) {
const hidden = hiddenChannels.has(el.dataset.channelId)
|| filteredOut(el.dataset.channelId, el.dataset.channelName);
el.classList.toggle('hidden-by-filter', hidden);
el.classList.toggle('hidden-by-filter', hiddenChannels.has(el.dataset.channelId));
}
function toggleChannel(id) {
@@ -130,7 +145,7 @@ export default function create({ root, moduleApi }) {
persistHidden();
rebuildChannelMenu();
entryEls.forEach((el) => applyFilter(el));
if (pinnedToBottom) scrollToBottom();
if (pinnedToLatest) scrollToLatest();
}
/** Show exactly this group's channels, hide the rest */
@@ -139,7 +154,7 @@ export default function create({ root, moduleApi }) {
persistHidden();
rebuildChannelMenu();
entryEls.forEach((el) => applyFilter(el));
if (pinnedToBottom) scrollToBottom();
if (pinnedToLatest) scrollToLatest();
}
function groupIsActive(g) {
@@ -150,18 +165,22 @@ export default function create({ root, moduleApi }) {
);
}
function updateChannelsLabel() {
/* The button is an icon, so the "some channels are hidden" state shows as
an accent tint plus a compact count beside it. */
function updateChannelsButton() {
let visibleCount = 0;
for (const id of channels.keys()) {
if (!hiddenChannels.has(id)) visibleCount++;
}
channelsMenu.setLabel(visibleCount === channels.size
? 'Channels ▾'
: `Channels (${visibleCount}/${channels.size}) ▾`);
const filtering = channels.size > 0 && visibleCount !== channels.size;
channelCountEl.textContent = filtering ? `${visibleCount}/${channels.size}` : '';
channelCountEl.hidden = !filtering;
channelsMenu.button.classList.toggle('active', filtering);
channelsMenu.button.title = filtering ? `Channels ${visibleCount}/${channels.size}` : 'Channels';
}
function rebuildChannelMenu() {
updateChannelsLabel();
updateChannelsButton();
const channelMenu = channelMenuEl;
if (!channelMenu) return; // menu not opened yet — the label is enough
channelMenu.innerHTML = '';
@@ -225,25 +244,33 @@ export default function create({ root, moduleApi }) {
/* ── scrolling ──────────────────────────────────────────────────── */
/** Newest entry at the top (bottom-up) instead of at the bottom? */
function newestFirst() {
return Boolean(moduleApi.instanceSettings.newestFirst);
}
streamEl.addEventListener('scroll', () => {
const nearBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
pinnedToBottom = nearBottom;
if (nearBottom) jumpBtn.hidden = true;
const near = newestFirst()
? streamEl.scrollTop < 60
: streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
pinnedToLatest = near;
if (near) jumpBtn.hidden = true;
});
jumpBtn.addEventListener('click', () => {
pinnedToBottom = true;
pinnedToLatest = true;
jumpBtn.hidden = true;
scrollToBottom();
scrollToLatest();
});
function scrollToBottom() {
streamEl.scrollTop = streamEl.scrollHeight;
/** The latest entry is at the top when flipped, at the bottom otherwise. */
function scrollToLatest() {
streamEl.scrollTop = newestFirst() ? 0 : streamEl.scrollHeight;
}
function afterInsert(el) {
if (el.classList.contains('hidden-by-filter')) return;
if (pinnedToBottom) scrollToBottom();
if (pinnedToLatest) scrollToLatest();
else jumpBtn.hidden = false;
}
@@ -272,7 +299,6 @@ export default function create({ root, moduleApi }) {
el = document.createElement('div');
el.className = 'pt-entry';
el.dataset.channelId = entry.channelId || '';
el.dataset.channelName = entry.channelName || '';
const info = channelInfo(entry);
el.style.setProperty('--ch', info.color);
@@ -314,7 +340,7 @@ export default function create({ root, moduleApi }) {
afterInsert(el);
} else {
updateEntryEl(el, entry);
if (pinnedToBottom && !el.classList.contains('hidden-by-filter')) scrollToBottom();
if (pinnedToLatest && !el.classList.contains('hidden-by-filter')) scrollToLatest();
}
}
@@ -327,125 +353,73 @@ export default function create({ root, moduleApi }) {
/* The stream sends JSON objects for new/updated/completed transcript
entries. Be liberal about the envelope shape. */
function extractEntries(obj) {
if (!obj || typeof obj !== 'object') return [];
if (Array.isArray(obj)) return obj.flatMap(extractEntries);
if (typeof obj.text === 'string' && (obj.id || obj.channelId)) return [obj];
for (const key of ['data', 'payload', 'entry', 'entries', 'transcript']) {
if (obj[key] && typeof obj[key] === 'object') {
const found = extractEntries(obj[key]);
if (found.length) return found;
}
}
return [];
/* ── the module's feed: one snapshot, then each change ─────────── */
function applyStatus(st) {
if (!st || typeof st !== 'object') return;
if (st.state === 'ok') moduleApi.setStatus('ok', st.message || 'Live');
else moduleApi.setStatus(st.state === 'error' ? 'error' : 'connecting', st.message || 'Connecting to ProdCom…');
}
function handleEventData(raw) {
let obj;
try { obj = JSON.parse(raw); } catch { return; }
const kind = String(obj.type || obj.event || '').toLowerCase();
if (kind.includes('clear')) {
entryEls.forEach((el) => el.remove());
entryEls.clear();
emptyEl.hidden = false;
return;
}
extractEntries(obj).forEach(upsertEntry);
}
/* ── data loading (all via the module's proxy) ──────────────────── */
async function loadChannels() {
const res = await moduleApi.fetch('/prodcom/api/v1/channels');
if (!res.ok) throw new Error('channels ' + res.status);
const body = await res.json();
function applySnapshot(snap) {
if (!snap || typeof snap !== 'object') return;
channels.clear();
for (const ch of body.data || []) {
channels.set(ch.id, { name: ch.name, color: ch.color || '#8b98a5' });
}
rebuildChannelMenu();
}
async function loadGroups() {
const res = await moduleApi.fetch('/prodcom/api/v1/groups');
if (!res.ok) throw new Error('groups ' + res.status);
const body = await res.json();
for (const ch of snap.channels || []) channels.set(String(ch.id), { name: ch.name, color: ch.color || '#8b98a5' });
groups.clear();
for (const g of body.data || []) {
groups.set(g.id, { name: g.name, channelIds: g.channelIds || [] });
}
for (const g of snap.groups || []) groups.set(String(g.id), { name: g.name, channelIds: g.channelIds || [] });
rebuildChannelMenu();
const list = (snap.entries || []).slice().sort((a, b) => new Date(a.date) - new Date(b.date));
list.forEach(upsertEntry); // dedupes by id, so a snapshot after a reconnect is harmless
applyStatus(snap.status);
}
async function loadHistory() {
const res = await moduleApi.fetch('/prodcom/api/v1/transcript?limit=100');
if (!res.ok) throw new Error('transcript ' + res.status);
const body = await res.json();
const entries = (body.data || []).slice();
entries.sort((a, b) => new Date(a.date) - new Date(b.date));
entries.forEach(upsertEntry);
async function loadState() {
try {
const res = await moduleApi.fetch('/state');
if (!res.ok) throw new Error(String(res.status));
applySnapshot(await res.json());
} catch { /* the stream's own snapshot covers it */ }
}
/* ── live stream ────────────────────────────────────────────────── */
const SSE_EVENT_NAMES = [
'transcript', 'transcript.new', 'transcript.updated', 'transcript.update',
'transcript.completed', 'transcript.complete', 'entry', 'new', 'update',
'updated', 'complete', 'completed',
];
function connect() {
const onEvent = (ev) => handleEventData(ev.data);
const events = {};
for (const name of SSE_EVENT_NAMES) events[name] = onEvent;
stream = moduleApi.sse('/prodcom/api/v1/transcript/stream', {
stream = moduleApi.sse('/stream', {
open() {
moduleApi.setStatus('ok', 'Live');
// refresh channels/groups and backfill entries missed while
// disconnected (upsertEntry dedupes by id, so this is harmless)
loadChannels().catch(() => {});
loadGroups().catch(() => {});
loadHistory().catch(() => {});
loadState(); // a late joiner, or a reconnect: catch up on what was missed
},
error() {
moduleApi.setStatus('error', 'ProdCom unreachable — reconnecting…');
moduleApi.setStatus('error', 'ProdDash ProdCom module unreachable — reconnecting…');
},
events: {
state(ev) {
try { applySnapshot(JSON.parse(ev.data)); } catch { /* a bad frame is skipped */ }
},
entry(ev) {
try { upsertEntry(JSON.parse(ev.data)); } catch { /* skipped */ }
},
clear() {
clearView();
},
status(ev) {
try { applyStatus(JSON.parse(ev.data)); } catch { /* skipped */ }
},
},
message: onEvent,
events,
});
}
/* ── lifecycle ──────────────────────────────────────────────────── */
async function boot() {
if (stopped) return;
moduleApi.setStatus('connecting', 'Connecting to ProdCom…');
try {
await loadChannels();
await loadGroups().catch(() => {}); // groups are optional
await loadHistory();
} catch {
moduleApi.setStatus('error', 'ProdCom unreachable — retrying…');
bootTimer = setTimeout(boot, 5000);
return;
}
scrollToBottom();
connect();
}
return {
start() {
boot();
moduleApi.setStatus('connecting', 'Connecting to ProdCom…');
connect();
},
stop() {
stopped = true;
clearTimeout(bootTimer);
stream?.close();
root.innerHTML = ''; // header controls are removed by the shell
},
onResize() {
if (pinnedToBottom) scrollToBottom();
if (pinnedToLatest) scrollToLatest();
},
};
}
+24 -18
View File
@@ -1,19 +1,35 @@
{
"id": "prodcom-transcript",
"name": "ProdCom",
"version": "1.0.0",
"version": "1.3.2",
"proddash": ">=1.1.0",
"description": "Live ProdCom transcript stream",
"description": "Live ProdCom transcript",
"permissions": [
{
"kind": "local-network",
"reason": "Reaching the ProdCom API on this network"
}
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": { "w": 3, "h": 2 },
"defaultSize": { "w": 4, "h": 4 },
"minSize": {
"w": 3,
"h": 2
},
"defaultSize": {
"w": 4,
"h": 4
},
"configSchema": {
"prodcom": {
"type": "endpoint",
"label": "ProdCom server",
"default": { "host": "10.3.11.152", "port": 24480 }
"default": {
"host": "",
"port": 24480
},
"help": "The ProdCom server at THIS location — every campus has its own. Blank until you set it, so a new install never reads another campus's comms."
},
"apiKey": {
"type": "password",
@@ -22,20 +38,10 @@
}
},
"instanceSchema": {
"channel": {
"type": "string",
"label": "Channel filter (channel name; empty = all)",
"default": ""
},
"textSize": {
"type": "number",
"label": "Text size (px)",
"default": 15
},
"showTimes": {
"showAvatars": {
"type": "boolean",
"label": "Show timestamps",
"default": false
"label": "Channel icons",
"default": true
}
}
}
+296 -74
View File
@@ -3,73 +3,34 @@
/**
* ProdCom Transcript — server part.
*
* One wildcard route that proxies /prodcom/* (including the SSE transcript
* stream) to the ProdCom API configured in the admin page, so browsers never
* talk to ProdCom directly (no CORS, and the API key never reaches clients).
* Ported from prodcom-listener/server.js.
* One connection to ProdCom for the whole server, however many dashboards
* show the transcript. The module holds the channel list, the groups and
* the last entries, follows ProdCom's SSE stream, and hands every browser a
* snapshot on connect plus each change as it happens. (It used to proxy each
* browser's stream to ProdCom one to one — N screens meant N upstream
* connections; ProdCom now sees one.)
*
* Routes (under /api/modules/prodcom-transcript):
* GET /state → { status, channels, groups, entries } (entries by date)
* GET /stream → SSE: `state` (the snapshot, first), then `entry` for each
* changed entry, `clear`, and `status` when the link changes
*
* ProdCom's API, as the reference listener speaks it:
* GET /api/v1/channels, /api/v1/groups, /api/v1/transcript?limit=N and the
* SSE /api/v1/transcript/stream, whose frames carry an entry, or a
* { type, entry | data } wrapper, in a few historical shapes (see
* extractEntries). A bearer API key is sent when one is configured.
*/
const http = require('http');
// Hop-by-hop headers that must not be forwarded
const HOP_HEADERS = new Set([
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
'te', 'trailer', 'transfer-encoding', 'upgrade',
]);
/** Health shown in the admin page: outcome of the most recent proxied request. */
let health = { status: 'connecting', message: 'No traffic yet' };
function proxy(req, res, target, apiKey) {
const upstreamPath = (req.wildcard || '/') + (req.search || '');
const headers = {};
for (const [k, v] of Object.entries(req.headers)) {
if (!HOP_HEADERS.has(k.toLowerCase())) headers[k] = v;
}
headers.host = target.host;
delete headers['accept-encoding']; // keep SSE unbuffered and simple
if (apiKey) headers.authorization = 'Bearer ' + apiKey;
const preq = http.request(
{
hostname: target.hostname,
port: target.port || 80,
path: upstreamPath,
method: req.method,
headers,
},
(pres) => {
health = { status: 'ok', message: `ProdCom reachable (last: HTTP ${pres.statusCode})` };
const outHeaders = {};
for (const [k, v] of Object.entries(pres.headers)) {
if (!HOP_HEADERS.has(k.toLowerCase())) outHeaders[k] = v;
}
res.writeHead(pres.statusCode, outHeaders);
// Flush immediately — SSE streams may send no bytes for a while, and
// the browser needs the headers to consider the stream open.
res.flushHeaders();
pres.pipe(res);
// If ProdCom dies mid-stream, pipe() unpipes on error without ending
// the browser's response, leaving SSE clients hanging on a dead
// stream. Destroy it so they notice immediately and reconnect.
pres.on('close', () => {
if (!res.writableEnded) res.destroy();
});
}
);
preq.on('error', (e) => {
health = { status: 'error', message: 'ProdCom unreachable: ' + (e.code || String(e)) };
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' });
}
res.end(JSON.stringify({ error: 'ProdCom unreachable', detail: e.code || String(e) }));
});
// Tear down the upstream connection when the browser disconnects
res.on('close', () => preq.destroy());
req.pipe(preq);
}
const HISTORY_LIMIT = 100; // entries asked for on (re)connect
const ENTRIES_MAX = 300; // entries kept in memory
const RETRY_MIN_MS = 2000;
const RETRY_MAX_MS = 15000;
const REQUEST_TIMEOUT_MS = 8000;
const REFRESH_LISTS_MS = 5 * 60 * 1000; // channels and groups re-read
const HEARTBEAT_MS = 15000;
/** The ProdCom base URL from the endpoint config. A legacy prodcomUrl (saved
before the endpoint field existed) can only still be present until the
@@ -82,23 +43,284 @@ function resolveTarget(config) {
return null;
}
/** 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 [];
if (Array.isArray(obj)) return obj.flatMap(extractEntries);
if (typeof obj.text === 'string' && (obj.id || obj.channelId)) return [obj];
for (const key of ['data', 'payload', 'entry', 'entries', 'transcript']) {
if (obj[key] && typeof obj[key] === 'object') {
const found = extractEntries(obj[key]);
if (found.length) return found;
}
}
return [];
}
/** One entry as the tiles read it; an entry without an id is keyed by channel and time. */
function normalizeEntry(e) {
const id = e.id !== undefined && e.id !== null && e.id !== '' ? String(e.id) : `${e.channelId || ''}|${e.date || ''}`;
return {
id,
channelId: e.channelId === undefined || e.channelId === null ? '' : String(e.channelId),
channelName: e.channelName ? String(e.channelName) : '',
text: String(e.text ?? ''),
date: e.date ? String(e.date) : new Date().toISOString(),
inProgress: Boolean(e.inProgress),
};
}
let current = null; // the running instance, for routes()
module.exports = {
init({ config }) {
health = resolveTarget(config)
? { status: 'connecting', message: 'No traffic yet' }
: { status: 'error', message: 'No ProdCom server configured' };
init({ config, log }) {
const target = resolveTarget(config);
const apiKey = String(config.apiKey || '');
const channels = [];
const groups = [];
const entries = new Map(); // id → entry, in arrival order
const streams = new Set(); // browsers' SSE responses
let status = { state: 'connecting', message: target ? 'Connecting to ProdCom…' : 'No ProdCom server configured' };
let stopped = false;
let upstream = null;
let generation = 0; // callbacks from a superseded connection are ignored
let retryTimer = null;
let retryMs = RETRY_MIN_MS;
const authHeaders = () => (apiKey ? { authorization: `Bearer ${apiKey}` } : {});
async function getJson(pathname) {
const res = await fetch(new URL(pathname, target), {
headers: { accept: 'application/json', ...authHeaders() },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`HTTP ${res.status} for ${pathname}`);
return res.json();
}
function sortedEntries() {
return [...entries.values()].sort((a, b) => new Date(a.date) - new Date(b.date));
}
function snapshot() {
return { status, channels, groups, entries: sortedEntries() };
}
function broadcast(event, data) {
if (!streams.size) return;
const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const res of streams) {
try {
res.write(frame);
} catch {
streams.delete(res);
}
}
}
function setStatus(state, message) {
if (status.state === state && status.message === message) return;
status = { state, message };
broadcast('status', status);
}
function trim() {
while (entries.size > ENTRIES_MAX) entries.delete(entries.keys().next().value);
}
/** A changed entry: kept, and told to every browser. */
function upsert(raw) {
const e = normalizeEntry(raw);
const prev = entries.get(e.id);
if (prev && prev.text === e.text && prev.inProgress === e.inProgress && prev.channelId === e.channelId) return;
entries.set(e.id, e);
trim();
broadcast('entry', e);
}
function clearAll() {
entries.clear();
broadcast('clear', {});
}
async function loadLists() {
const [ch, gr] = await Promise.all([
getJson('/api/v1/channels'),
getJson('/api/v1/groups').catch(() => ({ data: [] })), // groups are optional in ProdCom
]);
channels.splice(0, channels.length, ...(ch.data || []).map((c) => ({ id: String(c.id), name: String(c.name || c.id), color: c.color || '#8b98a5' })));
groups.splice(0, groups.length, ...(gr.data || []).map((g) => ({ id: String(g.id), name: String(g.name || g.id), channelIds: Array.isArray(g.channelIds) ? g.channelIds.map(String) : [] })));
}
async function loadHistory() {
const body = await getJson(`/api/v1/transcript?limit=${HISTORY_LIMIT}`);
for (const e of extractEntries(body).map(normalizeEntry)) entries.set(e.id, e);
trim();
}
function handleFrame(block) {
const data = [];
for (const line of block.split('\n')) {
if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
}
if (!data.length) return;
let obj;
try {
obj = JSON.parse(data.join('\n'));
} catch {
return;
}
const kind = String(obj?.type || obj?.event || '').toLowerCase();
if (kind.includes('clear')) return clearAll();
extractEntries(obj).forEach(upsert);
}
function scheduleRetry() {
if (stopped) return;
clearTimeout(retryTimer);
retryTimer = setTimeout(connect, retryMs);
retryMs = Math.min(RETRY_MAX_MS, retryMs * 2);
}
function fail(gen, why) {
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`);
scheduleRetry();
}
/** Lists and history first (a fresh snapshot for the tiles), then the live stream. */
async function connect() {
if (stopped || !target) return;
const gen = ++generation;
try {
await loadLists();
await loadHistory();
} catch (err) {
if (gen !== generation || stopped) return;
return fail(gen, err.cause?.code || err.code || err.message);
}
if (gen !== generation || stopped) return;
broadcast('state', snapshot());
const url = new URL('/api/v1/transcript/stream', target);
const req = http.request({
hostname: url.hostname,
port: url.port || 80,
path: url.pathname,
method: 'GET',
headers: { accept: 'text/event-stream', ...authHeaders() },
}, (res) => {
if (gen !== generation) return void res.destroy();
if (res.statusCode !== 200) {
res.resume();
return fail(gen, `HTTP ${res.statusCode} from the transcript stream`);
}
retryMs = RETRY_MIN_MS;
setStatus('ok', 'Live');
log(`following ${target.origin} — ${channels.length} channels, ${entries.size} entries`);
let buf = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
if (gen !== generation) return;
buf += chunk;
let i;
while ((i = buf.indexOf('\n\n')) >= 0) {
handleFrame(buf.slice(0, i));
buf = buf.slice(i + 2);
}
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));
});
req.on('error', (err) => fail(gen, err.code || err.message));
req.end();
upstream = req;
}
const listsTimer = setInterval(() => {
if (stopped || status.state !== 'ok') return;
loadLists().then(() => broadcast('state', snapshot())).catch(() => { /* the next reconnect re-reads them */ });
}, REFRESH_LISTS_MS);
listsTimer.unref?.();
const heartbeat = setInterval(() => {
for (const res of streams) {
try {
res.write(': ping\n\n');
} catch {
streams.delete(res);
}
}
}, HEARTBEAT_MS);
heartbeat.unref?.();
if (target) {
log(`one connection to ProdCom at ${target.origin}, shared by every dashboard`);
connect();
} else {
log('no ProdCom server configured — set the endpoint in /admin');
}
current = {
snapshot,
streams,
health() {
if (status.state === 'ok') return { status: 'ok', message: `Live — ${channels.length} channel${channels.length === 1 ? '' : 's'}, ${entries.size} entries` };
// Nothing to connect to is a setting that is missing, not a link that
// is coming up — it reads red, like the other modules say it.
if (!target) return { status: 'error', message: status.message };
return { status: status.state === 'error' ? 'error' : 'connecting', message: status.message };
},
};
return {
stop() { /* nothing persistent to tear down — streams die with their sockets */ },
health: () => health,
stop() {
stopped = true;
generation += 1;
clearTimeout(retryTimer);
clearInterval(listsTimer);
clearInterval(heartbeat);
try { upstream?.destroy(); } catch { /* already gone */ }
for (const res of streams) {
try { res.end(); } catch { /* gone */ }
}
streams.clear();
if (current?.streams === streams) current = null;
},
health: () => (current ? current.health() : { status: 'connecting', message: 'Restarting…' }),
};
},
routes({ config, log }) {
// An invalid host/URL throws here, which surfaces as a mount error in /admin.
const target = resolveTarget(config) || new URL('http://10.3.11.152:24480');
log(`proxying /prodcom/* → ${target.origin}`);
routes() {
const sendJson = (res, code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
};
return {
'* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''),
'GET /state': (req, res) => {
if (!current) return sendJson(res, 503, { error: 'ProdCom module is restarting — try again' });
sendJson(res, 200, current.snapshot());
},
'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) {
res.write(`event: state\ndata: ${JSON.stringify({ status: { state: 'connecting', message: 'ProdCom module is restarting…' }, channels: [], groups: [], entries: [] })}\n\n`);
return void res.end();
}
res.write(`event: state\ndata: ${JSON.stringify(current.snapshot())}\n\n`);
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
current.streams.add(res);
req.on('close', () => current && current.streams.delete(res));
},
};
},
};
+24
View File
@@ -9,6 +9,17 @@
flex-direction: column;
}
/* ── channels button ──
Icon-only; the count appears next to it while some channels are hidden. */
[data-module="prodcom-transcript"] .pt-channels-btn { gap: 3px; }
[data-module="prodcom-transcript"] .pt-count {
font-size: 10px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
/* ── channels menu ──
The menu container is created and positioned by the shell (.tile-menu);
it lives inside the tile, so [data-module] scoping still applies. */
@@ -113,6 +124,7 @@
background: color-mix(in srgb, var(--ch, var(--muted)) 17%, transparent);
}
[data-module="prodcom-transcript"] .pt-entry .avatar svg { width: 66%; height: 66%; display: block; }
[data-module="prodcom-transcript"] .hide-avatars .pt-entry .avatar { display: none; }
[data-module="prodcom-transcript"] .pt-entry .body { flex: 1; min-width: 0; }
@@ -143,6 +155,12 @@
[data-module="prodcom-transcript"] .pt-entry.hidden-by-filter { display: none; }
/* bottom-up: newest entry at the top, the stream reading upwards */
[data-module="prodcom-transcript"] .newest-first .pt-entries {
display: flex;
flex-direction: column-reverse;
}
/* ── jump-to-latest ── */
[data-module="prodcom-transcript"] .pt-jump {
@@ -163,3 +181,9 @@
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
}
[data-module="prodcom-transcript"] .pt-jump[hidden] { display: none; }
/* flipped, "latest" is the top of the stream — so is the pill */
[data-module="prodcom-transcript"] .newest-first .pt-jump {
top: 12px;
bottom: auto;
}
+39 -9
View File
@@ -6,6 +6,12 @@
in /admin; the tile only renders. */
export default function create({ root, moduleApi }) {
/* "now" is the live item on its own, filling the tile; "full" is that plus
the rest of the playlist. The picker presets `view`; a tile placed before
this module had a picker has neither, and gets the full card. */
const view = String(moduleApi.instanceSettings.view || moduleApi.variant || 'full');
const nowOnly = view === 'now';
let state = null;
let feedOffline = false;
let stream = null;
@@ -33,11 +39,13 @@ export default function create({ root, moduleApi }) {
</div>`;
const screenEl = root.querySelector('.nn-screen');
if (nowOnly) screenEl.classList.add('nn-only-now');
const topRow = root.querySelector('.nn-top');
const playlistNameEl = root.querySelector('.nn-playlist');
const liveEl = root.querySelector('.nn-live');
const liveTextEl = root.querySelector('.nn-live-text');
const nowEl = root.querySelector('.nn-now');
const nowLabelEl = root.querySelector('.nn-label');
const nowTitleBox = root.querySelector('.nn-title-box');
let nowTitleEl = root.querySelector('.nn-title');
const nowMetaEl = root.querySelector('.nn-meta');
@@ -47,9 +55,11 @@ export default function create({ root, moduleApi }) {
const nextListEl = root.querySelector('.nn-next-list');
const nextMoreEl = root.querySelector('.nn-next-more');
/** A setting, defaulting to on — a Now tile's schema omits the Next ones. */
function prefs() {
return moduleApi.instanceSettings; // showPlaylist / showProgress / cleanTitles
return moduleApi.instanceSettings;
}
const on = (key) => prefs()[key] !== false;
/* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */
@@ -65,7 +75,7 @@ export default function create({ root, moduleApi }) {
}
function displayName(raw) {
return prefs().cleanTitles ? cleanItemName(raw) : String(raw || '').trim();
return on('cleanTitles') ? cleanItemName(raw) : String(raw || '').trim();
}
function itemTypeTag(type) {
@@ -307,7 +317,9 @@ export default function create({ root, moduleApi }) {
const total = state.currentSlideTotal || activeItem?.slideCount || 0;
const current = state.currentSlide || 0;
if (total > 0 && current > 0) {
if (!on('showSlideCount')) {
nowMetaEl.textContent = '';
} else if (total > 0 && current > 0) {
const remaining = Math.max(0, total - current);
nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`;
} else if (total > 0) {
@@ -318,7 +330,7 @@ export default function create({ root, moduleApi }) {
nowMetaEl.textContent = '';
}
if (prefs().showProgress && total > 0) {
if (on('showProgress') && total > 0) {
progressEl.hidden = false;
const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0;
progressFillEl.style.width = `${pct}%`;
@@ -349,6 +361,10 @@ export default function create({ root, moduleApi }) {
nameEl.textContent = displayName(item.name) || '(untitled)';
row.appendChild(nameEl);
if (!on('showTags')) {
nextListEl.appendChild(row);
continue;
}
if (item.type === 'presentation' && Number.isInteger(item.slideCount)) {
const chip = document.createElement('div');
chip.className = 'nn-count-chip';
@@ -366,7 +382,7 @@ export default function create({ root, moduleApi }) {
nextListEl.appendChild(row);
}
nextLabelEl.hidden = upcoming.length === 0;
nextLabelEl.hidden = upcoming.length === 0 || !on('showNextLabel');
nextMoreEl.hidden = true;
markNextOverflow();
requestAnimationFrame(markNextOverflow);
@@ -374,12 +390,22 @@ export default function create({ root, moduleApi }) {
function render() {
screenEl.classList.toggle('is-offline', feedOffline);
const showTop = prefs().showPlaylist || feedOffline || !state?.reachable || !state?.enabled;
topRow.classList.toggle('hidden-row', !showTop);
playlistNameEl.textContent = prefs().showPlaylist ? state?.playlistName || '' : '';
// Trouble always shows, whatever is switched off: a tile that has stopped
// updating must never look like one with nothing live.
const trouble = feedOffline || !state?.reachable || !state?.enabled;
const wantPlaylist = on('showPlaylist');
const wantLive = on('showLive') || trouble;
topRow.classList.toggle('hidden-row', !wantPlaylist && !wantLive);
liveEl.hidden = !wantLive;
// Hidden, not just emptied: it is flex:1, so an emptied one still holds
// the row open and pushes the live dot off the right edge.
playlistNameEl.hidden = !wantPlaylist;
playlistNameEl.textContent = state?.playlistName || '';
nowLabelEl.hidden = !on('showNowLabel');
screenEl.classList.toggle('nn-no-next', nowOnly || !on('showNext'));
renderLive();
renderNow();
renderNext();
if (!nowOnly && on('showNext')) renderNext();
}
/* ── sizing: --unit scales the whole card to the tile ───────────── */
@@ -387,6 +413,10 @@ export default function create({ root, moduleApi }) {
function applyUnit() {
const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032));
screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px');
// On a Now tile the top row floats over the gradient, so at the shortest
// sizes it would sit on the title. Below the height it needs, it goes —
// the header dot and the title itself still report trouble.
screenEl.classList.toggle('nn-tight', nowOnly && root.clientHeight < unit * 9);
}
/* ── live feed ──────────────────────────────────────────────────── */
+80 -9
View File
@@ -1,29 +1,100 @@
{
"id": "propresenter-now-next",
"name": "ProPresenter",
"version": "1.0.0",
"proddash": ">=1.1.0",
"version": "1.1.1",
"proddash": ">=1.5.0",
"description": "What is live in ProPresenter and what comes next",
"permissions": [
{
"kind": "local-network",
"reason": "Reaching ProPresenter on this network"
}
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": { "w": 3, "h": 3 },
"defaultSize": { "w": 4, "h": 5 },
"minSize": {
"w": 3,
"h": 3
},
"defaultSize": {
"w": 4,
"h": 5
},
"configSchema": {
"propresenter": {
"type": "endpoint",
"label": "ProPresenter",
"default": { "host": "", "port": 1025 }
"default": {
"host": "",
"port": 1025
}
},
"password": {
"type": "password",
"label": "API password (only if set in ProPresenter)",
"label": "Password",
"default": ""
}
},
"instanceSchema": {
"showPlaylist": { "type": "boolean", "label": "Show the playlist name", "default": true },
"showProgress": { "type": "boolean", "label": "Show the slide progress bar", "default": true },
"cleanTitles": { "type": "boolean", "label": "Tidy titles (hide [ Full ] tags and date codes)", "default": true }
"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"
},
"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"
},
"showNext": {
"type": "switch",
"label": "Next list",
"default": true,
"group": "Show"
},
"showNextLabel": {
"type": "switch",
"label": "Next label",
"default": true,
"group": "Show"
},
"showTags": {
"type": "switch",
"label": "Item tags",
"default": true,
"group": "Show"
}
},
"instanceGroups": {
"Show": {
"collapsed": true,
"columns": 2
}
}
}
+37
View File
@@ -108,6 +108,43 @@ module.exports = {
};
},
/**
* Two ways to place this module. "Now / Next" is the full card — what is
* live, then the rest of the playlist. "Now" is the same live item alone,
* edge to edge with the gradient filling the tile: a wall display that says
* one thing. The Now entry offers only the settings it can act on, so its
* gear carries no Next switches.
*/
tiles() {
return [
{
id: 'full',
name: 'Now / Next',
description: 'What is live, and the rest of the playlist',
defaultSize: { w: 4, h: 5 },
minSize: { w: 3, h: 3 },
settings: { view: 'full' },
},
{
id: 'now',
name: 'Now',
description: 'Only what is live, filling the tile',
defaultSize: { w: 4, h: 2 },
minSize: { w: 2, h: 1 },
settings: { view: 'now' },
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' },
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' },
},
instanceGroups: { Show: { collapsed: true, columns: 2 } },
},
];
},
routes() {
return {
'GET /state': (req, res) => {
+48
View File
@@ -21,6 +21,51 @@
justify-content: center;
}
/* NEXT switched off, or a Now tile: the list goes and the card takes the room. */
[data-module="propresenter-now-next"] .nn-screen.nn-no-next .nn-next-wrap { display: none; }
[data-module="propresenter-now-next"] .nn-screen.nn-no-next { justify-content: center; }
/* ── the "Now" tile ──
Only what is live, edge to edge: the gradient moves off the card and onto
the whole tile, the card loses its border, radius and box, and the title
sits in the middle of everything. Nothing is inset, so a wall of these
reads as panels of colour rather than cards on a background. */
[data-module="propresenter-now-next"] .nn-screen.nn-only-now {
gap: 0;
background: linear-gradient(
160deg,
color-mix(in srgb, var(--accent) 14%, transparent),
color-mix(in srgb, var(--bg) 70%, transparent)
);
}
[data-module="propresenter-now-next"] .nn-only-now .nn-now {
flex: 1;
height: auto;
min-height: 0;
justify-content: center;
border: none;
border-radius: 0;
background: none;
padding: 0;
}
/* The live dot and playlist ride over the gradient rather than above it. */
[data-module="propresenter-now-next"] .nn-only-now .nn-top {
position: absolute;
inset: var(--nn-pad) var(--nn-pad) auto;
pointer-events: none;
}
[data-module="propresenter-now-next"] .nn-only-now .nn-title-box { margin-top: 0; }
/* The overlay floats, so it takes no room — this keeps the title clear of it
and still centred, by holding back as much at the bottom as at the top. */
[data-module="propresenter-now-next"] .nn-only-now:not(.nn-tight) .nn-now {
padding: calc(var(--unit) * 1.4) 0;
}
/* Too short for the overlay: the title gets the whole panel to itself. */
[data-module="propresenter-now-next"] .nn-only-now.nn-tight .nn-top,
[data-module="propresenter-now-next"] .nn-only-now.nn-tight.is-offline .nn-top {
display: none !important;
}
/* ── top row ── */
[data-module="propresenter-now-next"] .nn-top {
@@ -59,6 +104,7 @@
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="propresenter-now-next"] .nn-playlist[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-live {
display: flex;
@@ -71,6 +117,7 @@
text-transform: uppercase;
color: var(--muted);
}
[data-module="propresenter-now-next"] .nn-live[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-live-dot {
width: calc(var(--unit) * 0.6);
@@ -122,6 +169,7 @@
text-transform: uppercase;
color: var(--accent);
}
[data-module="propresenter-now-next"] .nn-label[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-title-box {
flex: 1;
File diff suppressed because it is too large Load Diff
+184 -13
View File
@@ -1,30 +1,73 @@
{
"id": "propresenter-timers",
"name": "Timers",
"version": "1.2.0",
"proddash": ">=1.2.0",
"description": "Live ProPresenter timers and LTC timecode",
"version": "1.4.1",
"proddash": ">=1.4.0",
"description": "Timers, Clocks, and LTC From configured Modules",
"provides": [
"timers"
],
"permissions": [
{
"kind": "microphone",
"reason": "Decoding SMPTE LTC timecode from an audio input on this machine"
},
{
"kind": "local-network",
"reason": "Reaching ProPresenter on this network"
}
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": { "w": 2, "h": 1 },
"defaultSize": { "w": 4, "h": 2 },
"minSize": {
"w": 2,
"h": 1
},
"defaultSize": {
"w": 4,
"h": 3
},
"configGroups": {
"ProPresenter": {
"toggle": "ppEnabled",
"help": "The timers configured in ProPresenter, read over its network API. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Switch it off on a day ProPresenter isn't running and nothing is polled."
},
"LTC Timecode": {
"toggle": "ltcEnabled",
"help": "SMPTE LTC decoded from an audio input on this machine, shown as a running timecode. Pick the device and the channel the timecode arrives on; macOS has to grant ProdDash the microphone (see the launcher's Permissions)."
},
"PCO Plan": {
"toggle": "pcoEnabled",
"help": "Timers the PCO Plan module offers — service elapsed, current item, countdown to service — appear here under a PCO Plan heading when that module is installed and configured. Nothing to set up on this side."
}
},
"configSchema": {
"ppEnabled": {
"type": "switch",
"label": "ProPresenter timers",
"group": "ProPresenter",
"default": true
},
"propresenter": {
"type": "endpoint",
"label": "ProPresenter",
"group": "ProPresenter",
"default": { "host": "", "port": 1025 }
"default": {
"host": "",
"port": 1025
}
},
"password": {
"type": "password",
"label": "API password (only if set in ProPresenter)",
"label": "API password",
"group": "ProPresenter",
"default": ""
"default": "",
"help": "Only if a password is set in ProPresenter's network preferences."
},
"ltcEnabled": {
"type": "switch",
"label": "LTC listener — decode timecode from an audio input on this machine",
"label": "LTC timecode",
"group": "LTC Timecode",
"default": false
},
@@ -38,10 +81,17 @@
},
"ltcChannel": {
"type": "number",
"label": "Channel on that device (1-based)",
"label": "Channel",
"group": "LTC Timecode",
"showWhen": "ltcEnabled",
"default": 1
"default": 1,
"help": "The channel on that device carrying the timecode, counted from 1."
},
"pcoEnabled": {
"type": "switch",
"label": "PCO Plan timers",
"group": "PCO Plan",
"default": true
},
"timerFont": {
"type": "select",
@@ -49,9 +99,130 @@
"group": "Display",
"default": "default",
"options": [
{ "value": "default", "label": "Default (system)" },
{ "value": "monospace", "label": "Monospace — steadier changing digits" }
{
"value": "default",
"label": "Default (system)"
},
{
"value": "monospace",
"label": "Monospace — steadier changing digits"
}
]
}
},
"instanceGroups": {
"Show": {
"collapsed": true,
"columns": 2
},
"Clock": {
"collapsed": false,
"columns": 2
},
"Colours": {
"collapsed": true,
"columns": 2
}
},
"instanceSchema": {
"showNames": {
"type": "switch",
"label": "Names",
"default": true,
"group": "Show"
},
"showStatus": {
"type": "switch",
"label": "Status lines",
"default": true,
"group": "Show"
},
"showDetail": {
"type": "switch",
"label": "Detail lines",
"default": true,
"group": "Show"
},
"showSeconds": {
"type": "switch",
"label": "Seconds",
"default": true,
"group": "Show"
},
"leadingZeros": {
"type": "switch",
"label": "Leading zeros",
"default": false,
"group": "Show"
},
"groupBySource": {
"type": "switch",
"label": "Group by source",
"default": false,
"group": "Show"
},
"showHeadings": {
"type": "switch",
"label": "Source headings",
"default": true,
"group": "Show",
"showWhen": "groupBySource"
},
"clock24h": {
"type": "switch",
"label": "24-hour",
"default": true,
"group": "Clock"
},
"clockSeconds": {
"type": "switch",
"label": "Seconds",
"default": true,
"group": "Clock"
},
"clockDate": {
"type": "switch",
"label": "Date",
"default": false,
"group": "Clock"
},
"dateShortDay": {
"type": "switch",
"label": "Short day",
"default": false,
"group": "Clock",
"showWhen": "clockDate"
},
"dateShortMonth": {
"type": "switch",
"label": "Short month",
"default": false,
"group": "Clock",
"showWhen": "clockDate"
},
"runningColor": {
"type": "color",
"label": "Running",
"default": "#2ee59a",
"group": "Colours"
},
"warningColor": {
"type": "color",
"label": "Warning",
"default": "#f0b429",
"group": "Colours"
},
"overrunColor": {
"type": "color",
"label": "Overrun",
"default": "#f4433c",
"group": "Colours"
},
"idleColor": {
"type": "color",
"label": "Idle",
"default": "#8b98a5",
"group": "Colours"
}
}
}
File diff suppressed because it is too large Load Diff
+249 -117
View File
@@ -1,11 +1,16 @@
/* ProPresenter Timers module — scoped to its tiles, shell variables only.
The API exposes no per-timer color, so a card's color IS its state:
accent = running, muted = stopped, warn = complete, danger = overrun.
Overrun must be unmistakable at a distance — danger fill + pulse. */
/* Timers module — scoped to its tiles, shell variables only.
A card's colour IS its state: running / warning / overrun / idle. The four
tones are custom properties on the tile root: they default to the shell's
palette (so a dashboard theme reaches them) and the tile's own colour
settings, when changed, are set inline on .tm-wrap and win — the
documented mechanism. Overrun must be unmistakable across the room:
danger fill + pulse. */
[data-module="propresenter-timers"] .tm-wrap {
--tm-time-size: 32px;
--tm-ltc-size: 26px;
--tm-ok: var(--accent);
--tm-warn: var(--warn);
--tm-danger: var(--danger);
--tm-muted: var(--muted);
--tm-meta-size: 11px;
position: absolute;
inset: 0;
@@ -15,7 +20,7 @@
-webkit-user-select: none;
}
/* ── degraded-state banner (feed lost / ProPresenter unreachable) ── */
/* ── degraded-state banner (the ProdDash feed itself is down) ── */
[data-module="propresenter-timers"] .tm-banner {
flex: none;
@@ -30,12 +35,12 @@
}
[data-module="propresenter-timers"] .tm-banner[hidden] { display: none; }
/* Values still on screen while the feed is down are stale — say so visually.
Per-card, because the reader-fed LTC card is not stale when only
ProPresenter is unreachable; it dims only when the ProdDash feed itself
is down (is-feed-down) and everything on screen really is old. */
[data-module="propresenter-timers"] .tm-wrap.is-stale .tm-card { opacity: 0.45; }
[data-module="propresenter-timers"] .tm-wrap.is-stale:not(.is-feed-down) .tm-card-ltc.src-reader { opacity: 1; }
/* Values still on screen while their source is down are stale — say so
visually, per group: the LTC card keeps ticking when only ProPresenter
is unreachable. When the ProdDash feed is down everything is old. */
[data-module="propresenter-timers"] .tm-group.is-stale .tm-card,
[data-module="propresenter-timers"] .tm-card.is-stale,
[data-module="propresenter-timers"] .tm-wrap.is-feed-down .tm-card { opacity: 0.45; }
[data-module="propresenter-timers"] .tm-hint {
flex: 1;
@@ -49,7 +54,69 @@
}
[data-module="propresenter-timers"] .tm-hint[hidden] { display: none; }
/* ── card grid (column count set by client.js fit()) ── */
/* ── source groups: heading + card grid (column count set by fit()) ── */
[data-module="propresenter-timers"] .tm-groups {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: var(--tm-gap, 8px);
padding: var(--tm-pad, 10px);
}
[data-module="propresenter-timers"] .tm-groups[hidden] { display: none; }
[data-module="propresenter-timers"] .tm-group {
min-height: 0;
display: flex;
flex-direction: column;
flex: 1 1 0;
}
[data-module="propresenter-timers"] .tm-head {
flex: none;
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
margin: 0 2px 4px;
font-size: 11px;
line-height: 16px;
color: var(--muted);
}
[data-module="propresenter-timers"] .tm-head[hidden] { display: none; }
[data-module="propresenter-timers"] .tm-title {
flex: none;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
/* The source's own word while it isn't OK ("Unreachable at …"). */
[data-module="propresenter-timers"] .tm-note {
flex: 1;
min-width: 0;
font-weight: 500;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
[data-module="propresenter-timers"] .tm-group.is-down .tm-note { color: var(--tm-warn); }
[data-module="propresenter-timers"] .tm-group.is-stale .tm-note { color: var(--tm-danger); }
/* Headings off: a source in trouble, or with nothing to show, still says so. */
[data-module="propresenter-timers"] .tm-empty {
flex: none;
padding: 2px 4px;
color: var(--muted);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="propresenter-timers"] .tm-empty[hidden] { display: none; }
[data-module="propresenter-timers"] .tm-group.is-down .tm-empty { color: var(--tm-warn); }
[data-module="propresenter-timers"] .tm-grid {
flex: 1;
@@ -57,16 +124,34 @@
display: grid;
grid-auto-rows: 1fr;
gap: var(--tm-gap, 8px);
padding: var(--tm-pad, 10px);
}
[data-module="propresenter-timers"] .tm-grid[hidden] { display: none; }
[data-module="propresenter-timers"] .tm-grid:empty { display: none; }
/* Compact cards (short tiles): the state line goes, its color stays.
The one exception is the LTC card with no timecode API behind it —
that message must never be hidden. */
[data-module="propresenter-timers"] .tm-compact .tm-card .tm-state { display: none; }
[data-module="propresenter-timers"] .tm-compact .tm-card-ltc.is-ltc-unavailable .tm-state { display: block; }
[data-module="propresenter-timers"] .tm-compact .tm-card.is-missing .tm-state { display: block; }
/* Grouping off: one grid for every timer, filled row by row. A wrapping
flex row instead of a CSS grid, so the last row's cards widen to use the
whole width — nothing is left empty. Rows share the height equally. */
[data-module="propresenter-timers"] .tm-grid.is-flow {
display: flex;
flex-wrap: wrap;
align-content: stretch;
align-items: stretch;
}
[data-module="propresenter-timers"] .tm-grid.is-flow .tm-card {
flex: 1 1 calc((100% - var(--tm-gap, 8px) * (var(--tm-cols, 1) - 1)) / var(--tm-cols, 1));
min-width: 0;
}
[data-module="propresenter-timers"] .tm-empty[data-tone="warn"] { color: var(--tm-warn); }
/* Compact cards (short tiles) and "Status lines" off: the lines under the
digits go, the colour stays. A note that explains a missing value (no
LTC input, a solo timer no longer offered) must never be hidden. */
[data-module="propresenter-timers"] .tm-compact .tm-status:not(.is-note),
[data-module="propresenter-timers"] .tm-compact .tm-detail,
[data-module="propresenter-timers"] .tm-nostatus .tm-status:not(.is-note),
[data-module="propresenter-timers"] .tm-nodetail .tm-detail { display: none; }
/* "Names" off hides every title; the clock never carries one — its digits say what it is */
[data-module="propresenter-timers"] .tm-nonames .tm-name,
[data-module="propresenter-timers"] .tm-card-clock .tm-name { display: none; }
/* ── one timer card ── */
@@ -98,13 +183,20 @@
text-transform: uppercase;
color: var(--muted);
white-space: nowrap;
}
/* The label is its own box so a long name ends in an ellipsis (a flex
container's bare text won't). */
[data-module="propresenter-timers"] .tm-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="propresenter-timers"] .tm-time {
flex: none;
font-size: var(--tm-time-size);
font-size: 32px; /* fit() sets the real size per card */
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
@@ -113,17 +205,17 @@
white-space: nowrap;
}
/* Admin "Timer font: Monospace" — steadier digits as the numbers change.
Applies to timer and LTC values alike (both are .tm-time). */
/* Admin "Timer font: Monospace" — steadier digits as the numbers change. */
[data-module="propresenter-timers"] .tm-mono .tm-time {
font-family: ui-monospace, "SF Mono", "Menlo", "Cascadia Mono", "Consolas", monospace;
letter-spacing: 0;
}
[data-module="propresenter-timers"] .tm-state {
[data-module="propresenter-timers"] .tm-status,
[data-module="propresenter-timers"] .tm-detail {
flex: none;
max-width: 100%;
font-size: var(--tm-meta-size);
font-size: calc(var(--tm-meta-size) * 0.8); /* smaller than the title; fit() budgets the same share */
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
@@ -132,102 +224,109 @@
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="propresenter-timers"] .tm-status[hidden],
[data-module="propresenter-timers"] .tm-detail[hidden] { display: none; }
/* ── state looks ── */
[data-module="propresenter-timers"] .tm-card.is-running {
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
[data-module="propresenter-timers"] .tm-card.is-running .tm-time { color: var(--accent); }
[data-module="propresenter-timers"] .tm-card.is-running .tm-state { color: var(--accent); }
[data-module="propresenter-timers"] .tm-card.is-stopped .tm-time { color: var(--muted); }
[data-module="propresenter-timers"] .tm-card.is-complete {
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
background: color-mix(in srgb, var(--warn) 10%, transparent);
}
[data-module="propresenter-timers"] .tm-card.is-complete .tm-time { color: var(--warn); }
[data-module="propresenter-timers"] .tm-card.is-complete .tm-state { color: var(--warn); }
/* A solo tile whose timer was deleted in ProPresenter. */
[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; }
[data-module="propresenter-timers"] .tm-card.is-missing .tm-time { color: var(--muted); }
[data-module="propresenter-timers"] .tm-card.is-missing .tm-state { color: var(--warn); }
/* Overrun: readable across the room even on a wall display. */
[data-module="propresenter-timers"] .tm-card.is-overrun {
border-color: var(--danger);
background: color-mix(in srgb, var(--danger) 22%, transparent);
animation: tm-overrun-pulse 1.2s ease-in-out infinite;
}
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-time { color: var(--danger); }
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-state {
color: var(--danger);
font-weight: 800;
[data-module="propresenter-timers"] .tm-detail {
font-weight: 600;
letter-spacing: 0.04em;
text-transform: none;
opacity: 0.85;
}
@keyframes tm-overrun-pulse {
50% { background: color-mix(in srgb, var(--danger) 38%, transparent); }
/* A provider's status line carries its own tone ("ahead 1:20" in green). */
[data-module="propresenter-timers"] .tm-status[data-tone="ok"] { color: var(--tm-ok); }
[data-module="propresenter-timers"] .tm-status[data-tone="warn"] { color: var(--tm-warn); }
[data-module="propresenter-timers"] .tm-status[data-tone="danger"] { color: var(--tm-danger); }
[data-module="propresenter-timers"] .tm-status[data-tone="muted"] { color: var(--tm-muted); }
/* The clock's date: one line when it fits, else the day on its own line
(renderDate() sizes it to the longer half). */
[data-module="propresenter-timers"] .tm-status.is-date {
white-space: normal;
overflow: visible;
text-overflow: clip;
text-align: center;
line-height: 1.15;
}
[data-module="propresenter-timers"] .tm-status.is-date span { white-space: nowrap; }
[data-module="propresenter-timers"] .tm-status.is-date.is-wrap span { display: block; }
/* ── the LTC card: visually its own thing ── */
[data-module="propresenter-timers"] .tm-card-ltc {
border-style: dashed;
}
[data-module="propresenter-timers"] .tm-card-ltc .tm-time {
font-size: var(--tm-ltc-size);
}
[data-module="propresenter-timers"] .tm-ltc-badge {
flex: none;
padding: 0.15em 0.55em;
border-radius: 5px;
background: color-mix(in srgb, var(--accent) 24%, transparent);
color: var(--text);
font-size: 0.95em;
letter-spacing: 0.12em;
}
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving {
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving .tm-time { color: var(--accent); }
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving .tm-state { color: var(--accent); }
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal {
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
}
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal .tm-time { color: var(--muted); }
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal .tm-state {
color: var(--warn);
animation: tm-nosignal-blink 1.6s steps(2) infinite;
}
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
/* LTC ceased mid-show: hold the last frame, quietly — no alarm colors,
stopping the clock is usually somebody doing their job. */
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-stopped .tm-time { color: var(--muted); }
/* No timecode API on this ProPresenter: say so, quietly. */
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-time { color: var(--muted); }
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-state {
/* Explanatory notes read as sentences, not badges. */
[data-module="propresenter-timers"] .tm-status.is-note {
text-transform: none;
letter-spacing: 0.02em;
white-space: normal;
text-align: center;
}
/* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */
/* ── state looks (feedbacks) ── */
[data-module="propresenter-timers"] .tm-card.is-ok {
border-color: color-mix(in srgb, var(--tm-ok) 55%, var(--border));
background: color-mix(in srgb, var(--tm-ok) 10%, transparent);
}
[data-module="propresenter-timers"] .tm-card.is-ok .tm-time { color: var(--tm-ok); }
[data-module="propresenter-timers"] .tm-card.is-warn {
border-color: color-mix(in srgb, var(--tm-warn) 55%, var(--border));
background: color-mix(in srgb, var(--tm-warn) 10%, transparent);
}
[data-module="propresenter-timers"] .tm-card.is-warn .tm-time { color: var(--tm-warn); }
[data-module="propresenter-timers"] .tm-card.is-muted .tm-time { color: var(--tm-muted); }
/* The wall clock is information, not a state — plain text colour. */
[data-module="propresenter-timers"] .tm-card.is-plain .tm-time { color: var(--text); }
/* Overrun: readable across the room even on a wall display. */
[data-module="propresenter-timers"] .tm-card.is-danger {
border-color: var(--tm-danger);
background: color-mix(in srgb, var(--tm-danger) 22%, transparent);
}
[data-module="propresenter-timers"] .tm-card.is-danger .tm-time { color: var(--tm-danger); }
[data-module="propresenter-timers"] .tm-card.is-overrun {
animation: tm-overrun-pulse 1.2s ease-in-out infinite;
}
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-status { font-weight: 800; }
@keyframes tm-overrun-pulse {
50% { background: color-mix(in srgb, var(--tm-danger) 38%, transparent); }
}
/* A solo tile whose timer is no longer offered. */
/* ── the LTC card: visually its own thing ── */
[data-module="propresenter-timers"] .tm-card-ltc { border-style: dashed; }
[data-module="propresenter-timers"] .tm-ltc-badge {
flex: none;
padding: 0.15em 0.55em;
border-radius: 5px;
background: color-mix(in srgb, var(--tm-ok) 24%, transparent);
color: var(--text);
font-size: 0.95em;
letter-spacing: 0.12em;
}
/* No signal: muted digits, a blinking warning line. */
[data-module="propresenter-timers"] .tm-card.is-nosignal {
border-color: color-mix(in srgb, var(--tm-warn) 55%, var(--border));
}
[data-module="propresenter-timers"] .tm-card.is-nosignal .tm-status {
animation: tm-nosignal-blink 1.6s steps(2) infinite;
}
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
/* ── the timers menu (container styled/positioned by the shell) ── */
[data-module="propresenter-timers"] .tm-menu-item {
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
gap: 14px;
width: 100%;
padding: 6px 9px;
background: none;
@@ -241,16 +340,41 @@
white-space: nowrap;
}
[data-module="propresenter-timers"] .tm-menu-item:hover { background: var(--border); }
[data-module="propresenter-timers"] .tm-menu-item .check {
width: 14px;
flex: none;
color: var(--accent);
font-weight: 700;
visibility: hidden;
}
[data-module="propresenter-timers"] .tm-menu-item.on .check { visibility: visible; }
[data-module="propresenter-timers"] .tm-menu-item .name { font-weight: 600; }
[data-module="propresenter-timers"] .tm-menu-item:not(.on) .name { color: var(--muted); }
/* A small switch: off = hollow pill, on = filled with the accent. (The menu
hangs off the tile's title bar, outside .tm-wrap, so the tile's own colour
properties don't reach it — the shell's palette does.) */
[data-module="propresenter-timers"] .tm-switch {
flex: none;
position: relative;
width: 26px;
height: 14px;
border-radius: 7px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--muted) 25%, transparent);
transition: background 0.15s ease, border-color 0.15s ease;
}
[data-module="propresenter-timers"] .tm-switch::after {
content: "";
position: absolute;
top: 1px;
left: 1px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text);
transition: transform 0.15s ease;
}
[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch {
border-color: var(--accent);
background: var(--accent);
}
[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch::after {
transform: translateX(12px);
background: var(--bg);
}
[data-module="propresenter-timers"] .tm-menu-note {
padding: 6px 9px;
@@ -258,3 +382,11 @@
font-size: 12px;
white-space: nowrap;
}
/* the count beside the title-bar timers icon, while some are hidden */
[data-module="propresenter-timers"] .tm-count {
font-size: 10px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
[data-module="propresenter-timers"] .tm-count[hidden] { display: none; }
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2013 Cal Henderson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+129
View File
@@ -0,0 +1,129 @@
{
"id": "slack",
"name": "Slack",
"version": "1.2.1",
"proddash": ">=1.4.0",
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": {
"w": 2,
"h": 2
},
"defaultSize": {
"w": 4,
"h": 4
},
"configGroups": {
"Slack account": {
"collapsed": "whenSet",
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
},
"Channels": {
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and until one is clicked, to the first channel here whose transcript is on that dashboard: with a single transcript up, that one. A send never goes to a channel the dashboard is not showing. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
},
"Sending": {
"toggle": "sendEnabled",
"help": "Messages and emoji reactions leave this machine only after the PIN is entered — on a Send or Quick replies tile, or in the reaction menu a right-click on a transcript message opens. One PIN unlocks every Slack tile on that dashboard page until Auto-lock minutes pass without a send; a reload asks again. Sending stays refused until a 4–8 digit PIN is set here. A message goes to the channel of the transcript clicked last. Quick emojis are sent with one press from the Send message tile and offered first when reacting. Quick replies are one per line, Label | Message, with an optional | #channel-name to bind that one to a channel: a bound reply always goes there, whichever transcript is selected."
}
},
"configSchema": {
"token": {
"type": "password",
"label": "Token",
"default": "",
"group": "Slack account",
"help": "The location account's User OAuth Token (xoxp-…, recommended) or a Bot User OAuth Token (xoxb-…)."
},
"sendAs": {
"type": "string",
"label": "Send as",
"default": "",
"group": "Slack account",
"help": "Only used with a bot token — a user token always sends as its own account; needs the chat:write.customize scope."
},
"channels": {
"type": "multiselect",
"label": "Channels",
"default": [],
"group": "Channels",
"optionsRoute": "/channels",
"help": "One transcript tile per channel; until a transcript is clicked, sends go to the first channel here that the dashboard is actually showing. Only channels the account has joined are listed — join one in Slack first."
},
"pollSeconds": {
"type": "number",
"label": "Refresh interval (s)",
"default": 2,
"group": "Transcript",
"help": "How often each channel is re-read (1 s at the fastest). With several channels the interval stretches so the module stays inside Slack's rate limit — at least 1.5 s × the number of channels; the health line shows the interval in use."
},
"historyCount": {
"type": "number",
"label": "Messages kept",
"default": 40,
"group": "Transcript"
},
"includeReplies": {
"type": "switch",
"label": "Thread replies",
"default": false,
"group": "Transcript"
},
"sendEnabled": {
"type": "switch",
"label": "Sending",
"default": false,
"group": "Sending"
},
"pin": {
"type": "password",
"label": "PIN",
"default": "",
"group": "Sending",
"help": "4–8 digits; the pad unlocks by itself once they are all in."
},
"pinIdleMinutes": {
"type": "number",
"label": "Auto-lock (min)",
"default": 10,
"group": "Sending"
},
"quickReplies": {
"type": "text",
"label": "Quick replies",
"default": "",
"group": "Sending",
"help": "One per line: Label | Message. Add | #channel-name to bind one to a channel — it then ignores which transcript is selected."
},
"quickEmojis": {
"type": "string",
"label": "Quick emojis",
"default": "👍 🙏 ✅ 👀 🎉",
"group": "Sending",
"help": "Emoji or :codes: separated by spaces, eight at most. One press sends one from the Send message tile; they also head the reaction menu."
}
},
"instanceSchema": {
"showAvatars": {
"type": "switch",
"label": "Avatars",
"default": true
},
"showTimes": {
"type": "switch",
"label": "Timestamps",
"default": true
},
"showNames": {
"type": "switch",
"label": "Names",
"default": true
},
"showSystem": {
"type": "switch",
"label": "Channel notices",
"default": true
}
}
}
File diff suppressed because it is too large Load Diff
+677
View File
@@ -0,0 +1,677 @@
/* Slack module — scoped to its tiles, shell variables only.
--sl-size (per tile, from the header's A− / A+) drives every font size.
Transcript rows follow the ProdCom transcript's layout: a square picture,
then a name line with the time, the text, the reactions. */
[data-module="slack"] .sl-wrap {
--sl-size: 14px;
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
container-type: size;
font-size: var(--sl-size);
color: var(--text);
}
[data-module="slack"] .sl-wrap:focus { outline: none; }
[data-module="slack"] [hidden] { display: none !important; }
/* ── degraded banner (transcript) ── */
[data-module="slack"] .sl-banner {
flex: none;
padding: 4px 10px;
font-size: 0.8em;
color: var(--warn);
background: color-mix(in srgb, var(--warn) 12%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--warn) 35%, var(--border));
}
[data-module="slack"] .sl-banner.is-error {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, transparent);
border-bottom-color: color-mix(in srgb, var(--danger) 35%, var(--border));
}
/* ── the send target ── */
/* A click anywhere in a transcript's body makes its channel the one Send
message and Quick replies post to. The chosen one carries a 3px accent
stripe along the bottom edge of the tile body — drawn just inside our
root, since the tile chrome around it belongs to the shell. The others
hint at it faintly on hover. */
[data-module="slack"] .sl-transcript::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: var(--accent);
opacity: 0;
pointer-events: none;
z-index: 3;
transition: opacity 0.15s ease;
}
[data-module="slack"] .sl-transcript:hover::after { opacity: 0.3; }
[data-module="slack"] .sl-transcript.is-target::after { opacity: 1; }
/* ── transcript stream ── */
[data-module="slack"] .sl-stream {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 6px 10px 10px;
overscroll-behavior: contain;
}
[data-module="slack"] .sl-empty {
color: var(--muted);
font-size: 0.9em;
text-align: center;
padding: 14% 10px 0;
line-height: 1.45;
}
[data-module="slack"] .sl-empty.is-error { color: var(--danger); }
[data-module="slack"] .sl-entry {
padding: 5px 6px;
border-radius: 8px;
line-height: 1.45;
}
[data-module="slack"] .sl-entry:hover { background: var(--bg); }
[data-module="slack"] .sl-entry.sl-new { animation: sl-fade-in 0.25s ease-out; }
@keyframes sl-fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
/* one row: the picture, then everything else (.sl-row — .sl-msg is the
send tile's status line, a different thing) */
[data-module="slack"] .sl-row,
[data-module="slack"] .sl-reply {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
}
[data-module="slack"] .sl-body { flex: 1; min-width: 0; }
/* the square: a profile image, a bot's icon or emoji, or initials on a tint
of a hue the name hashes to — the same size and corner as ProdCom's */
[data-module="slack"] .sl-avatar {
flex: none;
width: calc(var(--sl-size) * 1.9);
height: calc(var(--sl-size) * 1.9);
border-radius: 26%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: hsl(var(--sl-hue, 150) 45% 50% / 0.2);
color: hsl(var(--sl-hue, 150) 55% 60%);
font-size: 0.72em;
font-weight: 700;
letter-spacing: 0.02em;
user-select: none;
-webkit-user-select: none;
}
/* "Avatars" off: the square goes and the row starts at the left edge, as in ProdCom */
[data-module="slack"] .sl-wrap.hide-avatars .sl-avatar { display: none; }
[data-module="slack"] .sl-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
[data-module="slack"] .sl-avatar-emoji { font-size: 1.25em; line-height: 1; }
[data-module="slack"] .sl-reply .sl-avatar {
width: calc(var(--sl-size) * 1.5);
height: calc(var(--sl-size) * 1.5);
font-size: 0.6em;
}
[data-module="slack"] .sl-meta {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
margin-bottom: 2px;
}
[data-module="slack"] .sl-name {
font-weight: 700;
font-size: 0.82em;
letter-spacing: 0.01em;
color: var(--accent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="slack"] .sl-bot .sl-name { color: var(--muted); }
[data-module="slack"] .sl-time {
flex: none;
font-size: 0.72em;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
[data-module="slack"] .sl-wrap:not(.show-times) .sl-time { display: none; }
[data-module="slack"] .sl-wrap:not(.show-names) .sl-body > .sl-meta { display: none; }
[data-module="slack"] .sl-wrap.show-times:not(.show-names) .sl-body > .sl-meta { display: flex; }
[data-module="slack"] .sl-wrap.show-times:not(.show-names) .sl-name { display: none; }
[data-module="slack"] .sl-wrap:not(.show-system) .sl-entry.sl-system { display: none; }
[data-module="slack"] .sl-text {
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--text);
}
[data-module="slack"] .sl-text b { font-weight: 700; }
[data-module="slack"] .sl-text i { font-style: italic; }
[data-module="slack"] .sl-text s { color: var(--muted); }
[data-module="slack"] .sl-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.9em;
padding: 0 4px;
border-radius: 4px;
background: color-mix(in srgb, var(--text) 8%, transparent);
border: 1px solid var(--border);
}
[data-module="slack"] .sl-code.sl-block {
display: block;
padding: 4px 8px;
margin: 3px 0;
white-space: pre-wrap;
}
[data-module="slack"] .sl-edited {
font-size: 0.75em;
color: var(--muted);
white-space: nowrap;
}
/* a custom emoji is an image the size of a character */
[data-module="slack"] .sl-emoji {
display: inline-block;
width: 1.25em;
height: 1.25em;
object-fit: contain;
vertical-align: -0.28em;
}
/* reactions under a message: pills with the emoji and its count; the
location's own carry the accent — a click adds yours or takes it back */
[data-module="slack"] .sl-reactions {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
[data-module="slack"] .sl-reaction {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 7px 1px 5px;
border: 1px solid var(--border);
border-radius: 999px;
background: color-mix(in srgb, var(--text) 5%, transparent);
color: var(--text);
font: inherit;
font-size: 0.78em;
line-height: 1.4;
cursor: pointer;
transition: background 0.1s, border-color 0.1s;
}
[data-module="slack"] .sl-reaction:hover { border-color: var(--accent); }
[data-module="slack"] .sl-reaction.is-me {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 16%, transparent);
}
[data-module="slack"] .sl-reaction .sl-emoji,
[data-module="slack"] .sl-reaction .sl-emoji-char { font-size: 1.15em; line-height: 1; vertical-align: middle; }
[data-module="slack"] .sl-reaction-count {
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--muted);
}
[data-module="slack"] .sl-reaction.is-me .sl-reaction-count { color: var(--text); }
/* thread replies nest under their parent; without them, a muted count */
[data-module="slack"] .sl-replies {
margin: 4px 0 0 10px;
padding-left: 10px;
border-left: 2px solid var(--border);
display: flex;
flex-direction: column;
gap: 3px;
}
[data-module="slack"] .sl-reply { font-size: 0.92em; }
[data-module="slack"] .sl-replycount {
margin-top: 2px;
font-size: 0.75em;
color: var(--muted);
}
/* system lines: joins, leaves, topic changes */
[data-module="slack"] .sl-sysline {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 0.82em;
color: var(--muted);
font-style: italic;
}
[data-module="slack"] .sl-sysline .sl-time { font-style: normal; }
[data-module="slack"] .sl-jump {
position: absolute;
left: 50%;
bottom: 10px;
transform: translateX(-50%);
padding: 5px 12px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--accent);
color: var(--accent-text);
font: inherit;
font-size: 0.8em;
font-weight: 700;
cursor: pointer;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
}
/* ── send / quick replies: the gate ── */
[data-module="slack"] .sl-gate,
[data-module="slack"] .sl-compose {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 6px 10px;
overflow: auto;
}
[data-module="slack"] .sl-note {
margin: auto;
padding: 10px;
text-align: center;
color: var(--muted);
font-size: 0.9em;
line-height: 1.45;
}
[data-module="slack"] .sl-note.is-error { color: var(--danger); }
/* The pad never shrinks below its content (min-height stays `auto` on it
and on the key grid) — a shrunken grid would spill its rows under the
message line, which then swallows the taps. A tile too short for it
scrolls instead (.sl-gate has overflow: auto). */
[data-module="slack"] .sl-pad {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 4px;
max-width: 340px;
width: 100%;
margin: 0 auto;
}
[data-module="slack"] .sl-pad.sl-shake { animation: sl-shake 0.4s ease; }
@keyframes sl-shake {
10%, 90% { transform: translateX(-2px); }
20%, 80% { transform: translateX(3px); }
30%, 50%, 70% { transform: translateX(-5px); }
40%, 60% { transform: translateX(5px); }
}
/* one line: "Enter PIN · sending as Apollo Beach → #production" */
[data-module="slack"] .sl-pad-head {
flex: none;
text-align: center;
font-size: 0.8em;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="slack"] .sl-pad-head b { font-weight: 700; color: var(--text); }
[data-module="slack"] .sl-dots {
flex: none;
display: flex;
justify-content: center;
gap: 8px;
height: 14px;
}
[data-module="slack"] .sl-dot {
width: 11px;
height: 11px;
border-radius: 50%;
border: 2px solid var(--muted);
transition: background 0.12s, border-color 0.12s;
}
[data-module="slack"] .sl-dot.on { background: var(--accent); border-color: var(--accent); }
[data-module="slack"] .sl-keys {
flex: 1 1 auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(4, minmax(24px, 1fr));
gap: 4px;
}
[data-module="slack"] .sl-key {
min-width: 0;
min-height: 24px;
border: 1px solid var(--border);
border-radius: 10px;
background: color-mix(in srgb, var(--text) 5%, var(--panel));
color: var(--text);
font: inherit;
font-size: clamp(13px, 9cqh, 26px);
font-weight: 600;
font-variant-numeric: tabular-nums;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background 0.1s;
}
[data-module="slack"] .sl-key:hover { background: var(--border); }
[data-module="slack"] .sl-key:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
[data-module="slack"] .sl-key svg { width: 1.1em; height: 1.1em; }
[data-module="slack"] .sl-key-back { color: var(--muted); }
[data-module="slack"] .sl-key-blank { visibility: hidden; }
[data-module="slack"] .sl-pad.is-busy .sl-keys { opacity: 0.6; pointer-events: none; }
[data-module="slack"] .sl-pad-msg {
flex: none;
min-height: 1.1em;
text-align: center;
font-size: 0.78em;
line-height: 1.1;
color: var(--muted);
}
[data-module="slack"] .sl-pad-msg.is-error { color: var(--danger); font-weight: 600; }
/* A tile too short for the keypad (the smallest Send message / Quick replies
tiles) shows the dots and a hint instead — digits typed on the keyboard
still land, and a taller tile brings the keys back. .sl-wrap is the size
container the height is measured against. */
[data-module="slack"] .sl-pad-hint {
display: none;
flex: none;
text-align: center;
font-size: 0.78em;
line-height: 1.3;
color: var(--muted);
}
@container (max-height: 176px) {
[data-module="slack"] .sl-pad .sl-keys { display: none; }
[data-module="slack"] .sl-pad .sl-pad-hint { display: block; }
[data-module="slack"] .sl-pad { justify-content: center; }
}
/* ── send / quick replies: unlocked ── */
[data-module="slack"] .sl-compose { gap: 6px; }
[data-module="slack"] .sl-compose-head {
flex: none;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
font-size: 0.8em;
color: var(--muted);
}
[data-module="slack"] .sl-sending {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="slack"] .sl-lock-link {
flex: none;
padding: 1px 8px;
border: 1px solid var(--border);
border-radius: 999px;
background: none;
color: var(--muted);
font: inherit;
font-size: 0.9em;
cursor: pointer;
}
[data-module="slack"] .sl-lock-link:hover { color: var(--text); background: var(--border); }
[data-module="slack"] .sl-input {
flex: 1;
min-height: 2.6em;
width: 100%;
resize: none;
padding: 7px 9px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font: inherit;
line-height: 1.4;
}
[data-module="slack"] .sl-input:focus { outline: none; border-color: var(--accent); }
[data-module="slack"] .sl-compose-foot {
flex: none;
display: flex;
align-items: center;
gap: 8px;
min-height: 1.6em;
}
[data-module="slack"] .sl-msg {
flex: 1;
min-width: 0;
font-size: 0.8em;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="slack"] .sl-msg.is-flash { color: var(--accent); font-weight: 700; }
[data-module="slack"] .sl-msg.is-error { color: var(--danger); white-space: normal; font-weight: 600; }
[data-module="slack"] .sl-countdown {
flex: none;
font-size: 0.75em;
color: var(--warn);
font-variant-numeric: tabular-nums;
}
[data-module="slack"] .sl-sendbtn {
flex: none;
padding: 6px 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--accent-text);
font: inherit;
font-weight: 700;
cursor: pointer;
}
[data-module="slack"] .sl-sendbtn:hover { background: color-mix(in srgb, var(--accent) 85%, var(--text)); }
[data-module="slack"] .sl-sendbtn:disabled { opacity: 0.6; cursor: default; }
/* quick emojis: one press each sends it as a message of its own */
[data-module="slack"] .sl-emoji-sendrow {
flex: none;
display: flex;
gap: 4px;
min-width: 0;
overflow: hidden;
}
[data-module="slack"] .sl-emoji-send {
flex: none;
width: 2.1em;
height: 2.1em;
padding: 0;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--text) 5%, var(--panel));
color: var(--text);
font: inherit;
font-size: 1.1em;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background 0.1s, border-color 0.1s;
}
[data-module="slack"] .sl-emoji-send:hover { border-color: var(--accent); background: var(--border); }
[data-module="slack"] .sl-emoji-send:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
[data-module="slack"] .sl-emoji-send.is-busy { opacity: 0.6; }
[data-module="slack"] .sl-emoji-send.is-sent { background: var(--accent); border-color: transparent; }
[data-module="slack"] .sl-emoji-send .sl-emoji { width: 1.2em; height: 1.2em; vertical-align: middle; }
/* the preset grid: big targets, labels wrap */
[data-module="slack"] .sl-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr));
grid-auto-rows: minmax(44px, auto);
gap: 6px;
align-content: start;
overflow-y: auto;
}
[data-module="slack"] .sl-grid .sl-note { grid-column: 1 / -1; }
[data-module="slack"] .sl-quick {
min-width: 0;
min-height: 44px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 10px;
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
color: var(--text);
font: inherit;
font-weight: 600;
line-height: 1.25;
text-align: center;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background 0.1s;
}
[data-module="slack"] .sl-quick:hover { background: color-mix(in srgb, var(--accent) 22%, var(--panel)); }
[data-module="slack"] .sl-quick:active { background: color-mix(in srgb, var(--accent) 40%, var(--panel)); }
[data-module="slack"] .sl-quick.is-busy { opacity: 0.7; }
[data-module="slack"] .sl-quick.is-sent {
background: var(--accent);
border-color: transparent;
color: var(--accent-text);
}
[data-module="slack"] .sl-quick-label { overflow-wrap: anywhere; }
[data-module="slack"] .sl-quick-chan {
font-size: 0.72em;
font-weight: 500;
color: var(--muted);
}
[data-module="slack"] .sl-quick.is-sent .sl-quick-chan { color: inherit; }
/* ── the reaction menu (on document.body, so nothing clips it) ── */
[data-module="slack"].sl-emoji-menu {
--sl-size: 14px;
position: fixed;
z-index: 90;
width: 292px;
max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel);
color: var(--text);
font-size: var(--sl-size);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55);
overflow: hidden;
}
[data-module="slack"].sl-emoji-menu:focus { outline: none; }
[data-module="slack"].sl-emoji-menu.is-busy { opacity: 0.7; pointer-events: none; }
[data-module="slack"] .sl-emoji-head {
font-size: 0.8em;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="slack"] .sl-emoji-quickrow {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
[data-module="slack"] .sl-emoji-quickrow:empty { display: none; }
[data-module="slack"] .sl-emoji-btn {
width: 34px;
height: 34px;
padding: 0;
border: 1px solid transparent;
border-radius: 8px;
background: none;
color: var(--text);
font: inherit;
font-size: 20px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.1s, border-color 0.1s;
}
[data-module="slack"] .sl-emoji-quickrow .sl-emoji-btn { font-size: 22px; width: 38px; height: 38px; background: color-mix(in srgb, var(--text) 5%, var(--panel)); border-color: var(--border); }
[data-module="slack"] .sl-emoji-btn:hover { background: var(--border); border-color: var(--accent); }
[data-module="slack"] .sl-emoji-btn .sl-emoji { width: 1.1em; height: 1.1em; vertical-align: middle; }
[data-module="slack"] .sl-emoji-search {
width: 100%;
box-sizing: border-box;
padding: 6px 9px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 0.9em;
}
[data-module="slack"] .sl-emoji-search:focus { outline: none; border-color: var(--accent); }
[data-module="slack"] .sl-emoji-results {
display: flex;
flex-wrap: wrap;
gap: 2px;
max-height: 150px;
overflow-y: auto;
min-height: 34px;
}
[data-module="slack"] .sl-emoji-none {
width: 100%;
padding: 8px 4px;
text-align: center;
color: var(--muted);
font-size: 0.8em;
}
[data-module="slack"] .sl-emoji-msg {
min-height: 1em;
font-size: 0.78em;
color: var(--muted);
}
[data-module="slack"] .sl-emoji-msg:empty { display: none; }
[data-module="slack"] .sl-emoji-msg.is-error { color: var(--danger); font-weight: 600; }
/* the PIN pad inside the menu: fixed sizes, no tile to scale with */
[data-module="slack"] .sl-emoji-menu .sl-pad { max-width: 100%; gap: 6px; }
[data-module="slack"] .sl-emoji-menu .sl-keys { grid-template-rows: repeat(4, 40px); }
[data-module="slack"] .sl-emoji-menu .sl-key { font-size: 17px; }
+531
View File
@@ -0,0 +1,531 @@
/**
* SPL Meter tiles.
*
* Four kinds, all fed by one SSE stream from the module's server:
*
* readout the number, big
* bars one level bar per channel, with peak hold
* rta spectrum now, folded to the banding this tile wants
* spectrogram the same bands over time, either axis
*
* Nothing here measures anything. The capture child already sent every
* averaging and the finest banding, and each tile folds that down to the
* view it was configured for. That is the whole design: two dashboards can
* watch one room through different settings without either disturbing the
* measurement or the other dashboard.
*/
const FLOOR = -200;
/** Retention per frame for the RTA's smoothing setting. */
const SMOOTHING = { off: 0, light: 0.45, medium: 0.72, heavy: 0.9 };
export default function create({ root, moduleApi }) {
const variant = moduleApi.variant || 'readout';
let stream = null;
let latest = null;
let raf = 0;
root.classList.add('spl');
root.dataset.variant = variant;
const view = buildView(variant, root, moduleApi);
function onState(payload) {
latest = payload;
if (payload.error) {
moduleApi.setStatus('error', payload.error);
} else if (!payload.frame) {
moduleApi.setStatus('connecting', 'waiting for the first reading');
} else if (!payload.calibrated) {
moduleApi.setStatus('ok', 'Measuring, but uncalibrated — these are dBFS, not dB SPL');
} else {
moduleApi.setStatus('ok', '');
}
// Frames land 20×/s; paint on a frame the browser was going to draw
// anyway rather than fighting it for every one.
if (!raf) raf = requestAnimationFrame(() => { raf = 0; view.render(latest); });
}
return {
start() {
moduleApi.setStatus('connecting', 'connecting');
moduleApi.fetch('/state')
.then((r) => r.json())
.then(onState)
.catch(() => { /* the stream will bring it */ });
// Named events go under `events` — a top-level `state(ev)` is silently
// never registered, and the tile just sits on whatever /state gave it.
stream = moduleApi.sse('/stream', {
events: {
state(ev) {
try { onState(JSON.parse(ev.data)); } catch { /* keep the last good frame */ }
},
},
error() { moduleApi.setStatus('connecting', 'reconnecting'); },
});
},
stop() {
if (stream) { stream.close(); stream = null; }
if (raf) { cancelAnimationFrame(raf); raf = 0; }
view.destroy();
},
onResize() { view.resize(); },
};
}
/* ── shared helpers ─────────────────────────────────────────────────── */
function pickChannel(payload, settings) {
if (!payload || !payload.frame) return null;
const want = Number(settings.channel);
return payload.frame.ch.find((c) => c.i === want) || payload.frame.ch[0] || null;
}
/**
* The level this tile asked for: an averaging, or one of the fixed metrics.
*
* Tiles saved by an earlier version carry metrics that no longer exist —
* "spl" became an averaging, "leq" became Inf. A layout on a wall somewhere
* must not start reading dashes because a setting was renamed, so those map
* forward instead of falling through to undefined.
*/
const LEGACY_METRIC = { spl: 'level', leq: 'level' };
const LEGACY_AVERAGING = { leq: 'inf' };
function readValue(ch, settings) {
const raw = settings.metric || 'level';
const metric = LEGACY_METRIC[raw] || raw;
if (metric !== 'level') {
return metric in ch ? ch[metric] : ch.fast;
}
const avg = LEGACY_AVERAGING[raw] || settings.averaging || 'fast';
return avg in ch ? ch[avg] : ch.fast;
}
function fmt(db) {
if (!Number.isFinite(db) || db <= FLOOR) return '––.–';
return db.toFixed(1);
}
function themeColor(el, name, fallback) {
const v = getComputedStyle(el).getPropertyValue(name).trim();
return v || fallback;
}
function hzLabel(f) {
if (f >= 1000) {
const k = f / 1000;
return (k >= 10 ? Math.round(k) : Math.round(k * 10) / 10) + 'k';
}
return String(f >= 100 ? Math.round(f) : Math.round(f * 10) / 10);
}
/**
* Fold the 1/48-octave bands on the wire down to this tile's banding.
*
* Every banding offered divides 48, so a coarser band is an exact sum of
* finer ones in the power domain — no interpolation, no resampling. Groups
* are keyed on the 1 kHz band (n = 0), which is what keeps folded centres on
* the values an RTA is expected to show.
*
* @returns {{freq:number[], db:number[]}}
*/
function foldBands(bands, bandN0, perOctave, denom) {
const g = Math.max(1, Math.round(perOctave / denom));
const power = new Map();
for (let i = 0; i < bands.length; i++) {
const n = bandN0 + i;
const k = Math.round(n / g);
const p = 10 ** (bands[i] / 10);
power.set(k, (power.get(k) || 0) + p);
}
const keys = [...power.keys()].sort((a, b) => a - b);
return {
freq: keys.map((k) => 1000 * 2 ** ((k * g) / perOctave)),
db: keys.map((k) => 10 * Math.log10(Math.max(power.get(k), 1e-20))),
};
}
/**
* Which frequency labels fit, most important first.
*
* Decades earn their place before octaves, octaves before thirds, and
* anything that would collide with a label already placed is dropped. That
* is what makes the axis thin out as a tile shrinks instead of turning into
* a smear.
*/
const LABEL_TIERS = [
[100, 1000, 10000],
[31.5, 63, 250, 500, 2000, 4000, 8000, 20000],
[20, 40, 50, 80, 125, 160, 200, 315, 400, 630, 800, 1250, 1600, 2500, 3150, 5000, 6300, 12500, 16000],
];
function chooseLabels(freqs, positionOf, extent, minGapPx) {
const placed = [];
for (const tier of LABEL_TIERS) {
for (const f of tier) {
if (f < freqs[0] * 0.92 || f > freqs[freqs.length - 1] * 1.08) continue;
const pos = positionOf(f);
if (pos < 0 || pos > extent) continue;
if (placed.some((p) => Math.abs(p.pos - pos) < minGapPx)) continue;
placed.push({ f, pos });
}
}
return placed.sort((a, b) => a.pos - b.pos);
}
/* ── views ──────────────────────────────────────────────────────────── */
function buildView(variant, root, moduleApi) {
switch (variant) {
case 'bars': return barsView(root, moduleApi);
case 'rta': return spectrumView(root, moduleApi, false);
case 'spectrogram': return spectrumView(root, moduleApi, true);
default: return readoutView(root, moduleApi);
}
}
function readoutView(root, moduleApi) {
root.innerHTML = `
<div class="spl-readout">
<div class="spl-value"><span class="spl-num">––.–</span><span class="spl-unit">dB</span></div>
<div class="spl-sub"></div>
</div>`;
const num = root.querySelector('.spl-num');
const unit = root.querySelector('.spl-unit');
const sub = root.querySelector('.spl-sub');
const box = root.querySelector('.spl-readout');
const value = root.querySelector('.spl-value');
let fitKey = '';
/**
* Grow the number until it fills the tile, measuring the text we actually
* have rather than assuming a digit count.
*
* The CSS can only guess at width — it has to pick a cqw coefficient for
* some number of characters, and then "-100.5" overflows the tile that
* "-45.9" merely filled. Measuring costs one layout read, and only when
* the tile size or the length of the reading changes.
*/
function fit() {
const w = root.clientWidth, h = root.clientHeight;
const key = `${w}x${h}:${num.textContent.length}:${unit.textContent}:${sub.hidden}`;
if (key === fitKey || w < 2 || h < 2) return;
fitKey = key;
const TRIAL = 100;
num.style.fontSize = `${TRIAL}px`;
unit.style.fontSize = `${TRIAL * 0.26}px`;
const natural = value.scrollWidth || 1;
// Leave the caption its room when it is showing, and a little air either
// way so descenders and the tile's padding never clip.
const subH = sub.offsetParent ? sub.getBoundingClientRect().height + 6 : 0;
const byHeight = (h - subH) * 0.92;
const byWidth = ((w - 8) * TRIAL) / natural;
const size = Math.max(12, Math.min(byHeight, byWidth));
num.style.fontSize = `${size}px`;
unit.style.fontSize = `${size * 0.26}px`;
}
return {
render(payload) {
const s = moduleApi.instanceSettings;
const ch = pickChannel(payload, s);
if (!payload || payload.error || !ch) {
num.textContent = '––.–';
sub.textContent = payload && payload.error ? payload.error : '';
box.dataset.level = 'idle';
fit();
return;
}
const value = readValue(ch, s);
num.textContent = fmt(value);
unit.textContent = payload.calibrated ? 'dB' : 'dBFS';
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
box.dataset.level = !payload.calibrated ? 'idle'
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
const raw = s.metric || 'level';
const metric = LEGACY_METRIC[raw] || raw;
const avg = LEGACY_AVERAGING[raw] || s.averaging || 'fast';
const label = metric === 'level'
? ({ fast: 'Fast', slow: 'Slow', inf: 'Inf', none: 'No avg' }[avg] || 'Fast')
: ({ leq1m: 'Leq 1 min', leq10m: 'Leq 10 min', peakC: 'Peak C' }[metric] || metric);
const parts = [`${payload.weighting || '?'} · ${label}`, `ch ${ch.i}`];
if (ch.clip) parts.push('CLIP');
if (!payload.calibrated) parts.push('uncalibrated');
sub.hidden = s.caption === false;
sub.textContent = parts.join(' · ');
fit();
},
resize() { fitKey = ''; fit(); },
destroy() { root.innerHTML = ''; },
};
}
function barsView(root, moduleApi) {
root.innerHTML = '<div class="spl-bars"></div>';
const host = root.querySelector('.spl-bars');
let built = '';
return {
render(payload) {
if (!payload || !payload.frame) { host.textContent = payload?.error || ''; built = ''; return; }
const chans = payload.frame.ch;
const key = chans.map((c) => c.i).join(',');
if (key !== built) {
built = key;
host.innerHTML = chans.map((c) => `
<div class="spl-bar" data-ch="${c.i}">
<div class="spl-bar-label">${c.i}</div>
<div class="spl-bar-track"><div class="spl-bar-fill"></div><div class="spl-bar-peak"></div></div>
<div class="spl-bar-value">––.–</div>
</div>`).join('');
}
const s = moduleApi.instanceSettings;
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
const showPeak = s.peakHold !== false;
const lo = payload.calibrated ? 40 : -80;
const hi = payload.calibrated ? 110 : 0;
for (const c of chans) {
const el = host.querySelector(`.spl-bar[data-ch="${c.i}"]`);
if (!el) continue;
const value = readValue(c, { averaging: s.averaging });
const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
el.querySelector('.spl-bar-fill').style.width = `${pct(value)}%`;
const peak = el.querySelector('.spl-bar-peak');
peak.style.display = showPeak ? '' : 'none';
if (showPeak) peak.style.left = `${pct(c.peakC)}%`;
el.querySelector('.spl-bar-value').textContent = fmt(value);
el.dataset.level = !payload.calibrated ? 'idle'
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
el.classList.toggle('clip', !!c.clip);
}
},
resize() {},
destroy() { root.innerHTML = ''; },
};
}
/**
* RTA bars, or the same folded bands scrolling as a spectrogram.
*
* Canvas for both, because both redraw wholesale many times a second and a
* few hundred animating DOM nodes is how a dashboard starts dropping frames
* on the machine that is also running the service.
*/
function spectrumView(root, moduleApi, scrolling) {
root.innerHTML = '<canvas class="spl-canvas"></canvas>';
const canvas = root.querySelector('.spl-canvas');
const ctx = canvas.getContext('2d', { alpha: false });
let painted = false; // spectrogram: has the background been laid down
let smoothed = null; // RTA: displayed values, damped toward the real ones
let smoothedFor = '';
let axisBtn = null;
let axisOverride = null; // set by the title-bar toggle, which never remounts us
function axis() {
return axisOverride || moduleApi.instanceSettings.axis || 'x';
}
if (scrolling) {
// The axis lives here and nowhere else (rule 11): a tile does not offer
// the same action twice. Icon only, tooltip carries the words (rule 9).
// saveInstanceSettings deliberately does not remount us, so the new
// value is held here as well and read back by axis().
const swapIcon = '<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="M2 5h9M8.5 2 11.5 5 8.5 8"/><path d="M14 11H5M7.5 8 4.5 11 7.5 14"/></svg>';
const describe = () => (axis() === 'x' ? 'Time left to right' : 'Time top to bottom');
axisBtn = moduleApi.header.addButton({
icon: swapIcon,
title: describe(),
onClick() {
axisOverride = axis() === 'x' ? 'y' : 'x';
moduleApi.saveInstanceSettings({ axis: axisOverride });
painted = false; // the picture means nothing in the other orientation
axisBtn.title = describe();
},
});
}
function sizeCanvas() {
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.floor(root.clientWidth));
const h = Math.max(1, Math.floor(root.clientHeight));
if (canvas.width === Math.floor(w * dpr) && canvas.height === Math.floor(h * dpr)) return false;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
painted = false;
return true;
}
sizeCanvas();
function norm(db, calibrated) {
const lo = calibrated ? 30 : -100;
const hi = calibrated ? 110 : -10;
return Math.max(0, Math.min(1, (db - lo) / (hi - lo)));
}
function heat(v) {
const stops = [[10, 12, 20], [30, 90, 150], [90, 180, 140], [230, 190, 80], [220, 80, 60]];
const x = Math.max(0, Math.min(0.999, v)) * (stops.length - 1);
const i = Math.floor(x), t = x - i;
const a = stops[i], b = stops[i + 1] || stops[i];
return `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round(a[1] + (b[1] - a[1]) * t)},${Math.round(a[2] + (b[2] - a[2]) * t)})`;
}
return {
render(payload) {
sizeCanvas();
const w = canvas.clientWidth, h = canvas.clientHeight;
const bg = themeColor(root, '--panel', '#141414');
const muted = themeColor(root, '--muted', '#888');
const border = themeColor(root, '--border', '#333');
if (!payload || !payload.frame || !payload.frame.bands) {
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center';
ctx.fillText(payload?.error || 'Spectrum analysis is off', w / 2, h / 2);
painted = false;
return;
}
const s = moduleApi.instanceSettings;
const want = Number(s.channel);
const idx = Math.max(0, payload.frame.ch.findIndex((c) => c.i === want));
const raw = payload.frame.bands[idx] || payload.frame.bands[0] || [];
if (!raw.length) return;
const denom = Number(s.banding) || 3;
const folded = foldBands(raw, payload.bandN0 || 0, payload.bandsPerOctave || 48, denom);
const freqs = folded.freq;
let values = folded.db;
const grid = s.gridlines === true;
const showLabels = s.labels !== false;
const axisPad = showLabels || grid ? 13 : 0;
if (!scrolling) {
// Damp the bars toward the measurement. Cosmetic: `values` is what
// gets drawn, never what gets reported.
const keep = SMOOTHING[s.smoothing] ?? SMOOTHING.medium;
const key = `${denom}:${values.length}`;
if (smoothedFor !== key || !smoothed || smoothed.length !== values.length) {
smoothed = values.slice();
smoothedFor = key;
} else if (keep > 0) {
for (let i = 0; i < values.length; i++) {
smoothed[i] = smoothed[i] * keep + values[i] * (1 - keep);
}
} else {
smoothed = values.slice();
}
values = smoothed;
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
const plotH = h - axisPad;
const bw = w / values.length;
const xOf = (f) => (Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0])) * w;
const labels = chooseLabels(freqs, xOf, w, 34);
if (grid) {
ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.globalAlpha = 0.5;
ctx.beginPath();
for (const l of labels) {
const x = Math.round(l.pos) + 0.5;
ctx.moveTo(x, 0); ctx.lineTo(x, plotH);
}
for (let g = 0.2; g < 1; g += 0.2) {
const y = Math.round(plotH * g) + 0.5;
ctx.moveTo(0, y); ctx.lineTo(w, y);
}
ctx.stroke(); ctx.globalAlpha = 1;
}
for (let i = 0; i < values.length; i++) {
const v = norm(values[i], payload.calibrated);
const bh = v * plotH;
ctx.fillStyle = heat(v);
ctx.fillRect(i * bw, plotH - bh, Math.max(1, bw - (bw > 3 ? 1 : 0)), bh);
}
if (showLabels) {
ctx.fillStyle = muted; ctx.font = '9px system-ui'; ctx.textAlign = 'center';
for (const l of labels) ctx.fillText(hzLabel(l.f), l.pos, h - 3);
}
return;
}
// ── spectrogram ──
const timeX = axis() === 'x';
const colW = 2;
const gutter = showLabels ? axisPad : 0;
// Frequency occupies whichever axis time does not.
const freqExtent = timeX ? h - gutter : w - gutter;
const timeExtent = timeX ? w : h;
if (!painted) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); painted = true; }
// Source rect in device pixels, destination in CSS pixels: the context
// carries a devicePixelRatio transform but the canvas as an image
// source does not, and letting drawImage infer them scales the picture
// up and pushes every column off the canvas.
if (timeX) {
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -colW, 0, w, h);
} else {
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, -colW, w, h);
}
const bandExtent = freqExtent / values.length;
for (let i = 0; i < values.length; i++) {
ctx.fillStyle = heat(norm(values[i], payload.calibrated));
if (timeX) {
// Low frequencies at the bottom, as every spectrogram has them.
ctx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent,
colW, Math.ceil(bandExtent) + 1);
} else {
// Low frequencies at the left.
ctx.fillRect(gutter + i * bandExtent, timeExtent - colW,
Math.ceil(bandExtent) + 1, colW);
}
}
if (grid || showLabels) {
const posOf = (f) => {
const t = Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0]);
return timeX ? freqExtent - t * freqExtent : gutter + t * freqExtent;
};
const labels = chooseLabels(freqs, posOf, timeX ? freqExtent : w, timeX ? 16 : 34);
ctx.fillStyle = bg;
if (timeX) ctx.fillRect(0, h - gutter, w, gutter);
else ctx.fillRect(0, 0, gutter, h);
ctx.fillStyle = muted; ctx.font = '9px system-ui';
ctx.strokeStyle = border; ctx.globalAlpha = grid ? 0.35 : 0; ctx.beginPath();
for (const l of labels) {
if (timeX) {
const y = Math.round(l.pos) + 0.5;
ctx.moveTo(0, y); ctx.lineTo(w, y);
} else {
const x = Math.round(l.pos) + 0.5;
ctx.moveTo(x, 0); ctx.lineTo(x, h);
}
}
ctx.stroke(); ctx.globalAlpha = 1;
ctx.textAlign = timeX ? 'left' : 'center';
for (const l of showLabels ? labels : []) {
if (timeX) ctx.fillText(hzLabel(l.f), 2, Math.min(h - gutter - 2, l.pos + 3));
else ctx.fillText(hzLabel(l.f), l.pos, h - 3);
}
}
},
resize() { sizeCanvas(); },
destroy() { root.innerHTML = ''; },
};
}
+94
View File
@@ -0,0 +1,94 @@
{
"id": "spl-meter",
"name": "SPL Meter",
"version": "0.3.1",
"proddash": ">=1.5.0",
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
"permissions": [
{
"kind": "microphone",
"reason": "Measuring sound level from an audio input on this machine"
}
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": {
"w": 2,
"h": 1
},
"defaultSize": {
"w": 3,
"h": 2
},
"configGroups": {
"Input": {
"toggle": "enabled"
},
"Measurement": {
"columns": 2
},
"Calibration": {
"collapsed": "whenSet",
"help": "A sound level calibrator fits over the microphone capsule and emits a known level, almost always 94 dB at 1 kHz. Put it on, press Measure beside the channel, and copy the offset it reports into the box. Without this the meter still works, but it reads in dB relative to full scale and every tile says so."
}
},
"configSchema": {
"enabled": {
"type": "switch",
"label": "Sound level measurement",
"group": "Input",
"default": false
},
"device": {
"type": "select",
"label": "Audio device",
"group": "Input",
"showWhen": "enabled",
"default": "",
"optionsRoute": "/devices"
},
"channels": {
"type": "string",
"label": "Channels on that device (1-based, comma separated)",
"group": "Input",
"showWhen": "enabled",
"default": "1",
"help": "One meter per channel — \"1\" for a single measurement mic, \"1,2\" for a stereo pair."
},
"weighting": {
"type": "select",
"label": "Weighting",
"group": "Measurement",
"default": "A",
"options": [
{
"value": "A",
"label": "A — how loud it sounds"
},
{
"value": "C",
"label": "C — keeps the low end"
},
{
"value": "Z",
"label": "Z — no weighting at all"
}
]
},
"bands": {
"type": "switch",
"label": "Spectrum analysis",
"group": "Measurement",
"default": true,
"help": "1/48-octave bands for the RTA and spectrogram tiles, which each fold them to their own banding. Turn it off if you only ever show the number."
},
"calibration": {
"type": "string",
"label": "Offsets in dB, one per channel",
"group": "Calibration",
"default": "",
"help": "Leave empty and every reading is dB relative to full scale, labelled dBFS so nobody mistakes it for real SPL."
}
}
}
+407
View File
@@ -0,0 +1,407 @@
'use strict';
/**
* SPL Meter server entry.
*
* Owns one capture child (spl-listener.js) and fans its frames out to every
* tile over SSE. Browsers never see the audio device, only the numbers.
*
* GET /stream → SSE, `state` events at the capture rate
* GET /state → the latest frame, for an instant first paint
* GET /devices → input devices, for the admin page's picker
* GET /channels → configured channels, for the tile gear picker
* POST /reset → clear Leq and peak hold
* GET /measure → what a calibrator is reading right now, so the
* admin page can tell the operator what to type
*
* Frames arrive 20×/s. That is the right rate for a meter to feel live and
* the wrong rate to re-broadcast blindly, so the SSE fan-out drops a frame
* whose numbers round to what the last one said.
*/
const path = require('path');
const { createSplListener } = require('./spl-listener');
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
const CALIBRATOR_DB = 94;
/** How long /measure averages for. Long enough to ride out a wobble, short
enough that nobody standing at the microphone gets bored. */
const MEASURE_MS = 3000;
/** SSE comment ping — keeps idle connections alive through sleepy Wi-Fi. */
const PING_MS = 15000;
/** Set by init(), read by routes() — both are rebuilt together on re-init. */
let state = null;
/* ── shared tile settings ───────────────────────────────────────────── */
const CHANNEL_FIELD = {
type: 'select', label: 'Channel', default: '', optionsRoute: '/channels',
};
/**
* Averaging is a view choice, not a measurement one: the capture child
* computes all four every frame and sends all four, so one dashboard on Fast
* and another on Inf are reading the same measurement two ways.
*/
function averagingField() {
return {
type: 'select', label: 'Averaging', default: 'fast',
options: [
{ value: 'fast', label: 'Fast · 125 ms' },
{ value: 'slow', label: 'Slow · 1 s' },
{ value: 'inf', label: 'Inf · since reset' },
{ value: 'none', label: 'None' },
],
};
}
/**
* Banding folds the 1/48-octave bands on the wire down to something
* readable. Coarser is an exact sum of finer, so this costs nothing and
* disturbs no other tile.
*/
const BANDING_FIELD = {
type: 'select', label: 'Banding', default: '3',
options: [
{ value: '3', label: '1/3 octave' },
{ value: '6', label: '1/6 octave' },
{ value: '12', label: '1/12 octave' },
{ value: '24', label: '1/24 octave' },
{ value: '48', label: '1/48 octave' },
],
};
function parseChannels(raw) {
return String(raw || '1')
.split(',')
.map((s) => Number(s.trim()))
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 64);
}
function parseCalibration(raw) {
if (!String(raw || '').trim()) return [];
return String(raw)
.split(',')
.map((s) => Number(s.trim()))
.filter((n) => Number.isFinite(n));
}
module.exports = {
init({ config, log }) {
const channels = parseChannels(config.channels);
const calibration = parseCalibration(config.calibration);
const moduleDir = __dirname;
state = {
channels,
calibrated: calibration.length === channels.length && channels.length > 0,
frame: null,
meta: null,
error: '',
streams: new Set(),
listener: null,
lastSent: '',
pinger: null,
};
if (!config.enabled) {
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
}
if (!config.device) {
state.error = 'No audio device chosen yet';
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
}
if (!channels.length) {
state.error = `Cannot read "${config.channels}" as a channel list — try 1, or 1,2`;
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
}
if (calibration.length && calibration.length !== channels.length) {
state.error = `${channels.length} channel(s) but ${calibration.length} calibration offset(s) — `
+ 'give one per channel, or leave the offsets empty';
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
}
state.listener = createSplListener({
device: config.device,
channels,
weighting: config.weighting || 'A',
bands: config.bands !== false,
calibration,
moduleDir,
log,
onUpdate(update) {
if (!state) return; // a frame in flight when the module unmounted
if (update.error) {
if (update.error !== state.error) log(update.error);
state.error = update.error;
state.frame = null;
broadcast();
return;
}
state.error = '';
state.frame = update.frame;
state.meta = update.meta;
broadcast();
},
});
const mineStreams = state.streams;
state.pinger = setInterval(() => {
for (const res of mineStreams) {
try { res.write(': ping\n\n'); } catch { mineStreams.delete(res); }
}
}, PING_MS);
return {
stop() { teardown(); },
health() {
if (state.error) return { status: 'error', message: state.error };
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
const n = state.channels.length;
const what = `${n} channel${n === 1 ? '' : 's'} on ${config.device}`;
return state.calibrated
? { status: 'ok', message: what }
: { status: 'ok', message: `${what} — uncalibrated, reading dBFS` };
},
};
},
/**
* Four tile kinds, each with only the settings that do something to it.
*
* An entry's own instanceSchema replaces the module's, which is the point:
* an RTA has no use for an alarm threshold and a readout has no use for a
* banding, and offering either is how a gear popover stops being read.
*
* Every setting here is a view choice. None of them reach the capture
* child — it already sends every averaging and the finest banding, so two
* dashboards can disagree about how to draw the same room.
*/
tiles() {
return [
{
id: 'readout', name: 'SPL', description: 'The level, big',
defaultSize: { w: 2, h: 1 }, minSize: { w: 1, h: 1 },
instanceGroups: { Thresholds: { columns: 2 } },
instanceSchema: {
channel: CHANNEL_FIELD,
averaging: averagingField(),
metric: {
type: 'select', label: 'Show', default: 'level',
options: [
{ value: 'level', label: 'Level' },
{ value: 'leq1m', label: 'Leq 1 min' },
{ value: 'leq10m', label: 'Leq 10 min' },
{ value: 'peakC', label: 'Peak C' },
],
},
caption: { type: 'switch', label: 'Caption', default: true },
warnAt: { type: 'number', label: 'Amber above', group: 'Thresholds', default: 95 },
alarmAt: { type: 'number', label: 'Red above', group: 'Thresholds', default: 100 },
},
},
{
id: 'bars', name: 'SPL Levels', description: 'One bar per channel, with peak hold',
defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 },
instanceGroups: { Thresholds: { columns: 2 } },
instanceSchema: {
averaging: averagingField(),
peakHold: { type: 'switch', label: 'Peak hold', default: true },
warnAt: { type: 'number', label: 'Amber above', group: 'Thresholds', default: 95 },
alarmAt: { type: 'number', label: 'Red above', group: 'Thresholds', default: 100 },
},
},
{
id: 'rta', name: 'RTA', description: 'Spectrum now, at your choice of banding',
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 },
instanceSchema: {
channel: CHANNEL_FIELD,
averaging: averagingField(),
banding: BANDING_FIELD,
smoothing: {
type: 'select', label: 'Smoothing', default: 'medium',
options: [
{ value: 'off', label: 'Off' },
{ value: 'light', label: 'Light' },
{ value: 'medium', label: 'Medium' },
{ value: 'heavy', label: 'Heavy' },
],
},
labels: { type: 'switch', label: 'Labels', default: true },
gridlines: { type: 'switch', label: 'Gridlines', default: false },
},
},
{
id: 'spectrogram', name: 'Spectrogram', description: 'Spectrum over time',
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 },
instanceSchema: {
channel: CHANNEL_FIELD,
banding: BANDING_FIELD,
labels: { type: 'switch', label: 'Labels', default: true },
gridlines: { type: 'switch', label: 'Gridlines', default: false },
},
},
];
},
routes({ config, log }) {
return {
'GET /state': (req, res) => {
json(res, 200, snapshot());
},
'GET /stream': (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
});
res.write('retry: 3000\n\n');
try {
res.write(`event: state\ndata: ${JSON.stringify(snapshot())}\n\n`);
} catch { return; }
// Hold this mount's set, not `state`. teardown() nulls `state` and
// then ends every stream, which fires these close handlers — a
// handler that reached through `state` threw on null and took the
// whole server down with it, on any re-init with a browser
// connected. Deleting from a set nobody reads any more is harmless.
const streams = state.streams;
streams.add(res);
req.on('close', () => streams.delete(res));
},
/** Device list for the admin page. Runs the capture tool's --list,
which is the only thing that knows what CoreAudio can see. */
'GET /devices': async (req, res) => {
try {
const devices = await listDevices();
json(res, 200, {
options: devices.map((d) => ({
value: d.name,
label: `${d.name} — ${d.channels} ch @ ${Math.round(d.sampleRate)} Hz`,
})),
});
} catch (err) {
log(`device list failed: ${err.message}`);
json(res, 200, { options: [] }); // the saved value stays selectable
}
},
/** Channel list for a tile's gear popover. */
'GET /channels': (req, res) => {
json(res, 200, {
options: state.channels.map((c) => ({ value: String(c), label: `Channel ${c}` })),
});
},
'POST /reset': (req, res) => {
if (state.listener) state.listener.reset();
json(res, 200, { ok: true });
},
/**
* What the microphone is hearing right now, unweighted and
* uncalibrated, plus the offset that would make it read 94 dB.
*
* The module cannot write its own admin config, so this does not save
* anything — it hands back a number for the operator to paste into the
* Calibration box. Honest, if one step longer than it should be.
*/
'GET /measure': (req, res) => {
if (!state.listener || state.error) {
json(res, 409, { error: state.error || 'not measuring' });
return;
}
const wanted = Number(new URLSearchParams(req.search || '').get('channel'))
|| state.channels[0];
const samples = [];
const started = Date.now();
const tick = setInterval(() => {
if (!state) { clearInterval(tick); return; } // module unmounted mid-measure
const ch = state.frame && state.frame.ch.find((c) => c.i === wanted);
if (ch && Number.isFinite(ch.dbfs) && ch.dbfs > -200) samples.push(ch.dbfs);
if (Date.now() - started < MEASURE_MS) return;
clearInterval(tick);
if (samples.length < 5) {
json(res, 200, {
channel: wanted,
error: 'No steady tone on that channel — check the calibrator is on and seated',
});
return;
}
// Mean of the linear powers, not of the decibels: averaging dB
// would quietly under-read a signal that wobbles at all.
const power = samples.reduce((s, db) => s + 10 ** (db / 10), 0) / samples.length;
const measured = 10 * Math.log10(power);
json(res, 200, {
channel: wanted,
measured: Math.round(measured * 100) / 100,
reference: CALIBRATOR_DB,
offset: Math.round((CALIBRATOR_DB - measured) * 100) / 100,
samples: samples.length,
});
}, 100);
req.on('close', () => clearInterval(tick));
},
};
},
};
/* ── helpers ────────────────────────────────────────────────────────── */
function snapshot() {
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null };
return {
error: state.error,
calibrated: state.calibrated,
channels: state.channels,
weighting: state.meta ? state.meta.weighting : '',
bandCentres: state.meta ? state.meta.bands : [],
bandN0: state.meta ? state.meta.bandN0 : 0,
bandsPerOctave: state.meta ? state.meta.bandsPerOctave : 48,
frame: state.frame,
};
}
function broadcast() {
if (!state) return; // a listener callback landing after teardown
const payload = snapshot();
const line = JSON.stringify(payload);
if (line === state.lastSent) return; // nothing moved since last frame
state.lastSent = line;
for (const res of state.streams) {
try { res.write(`event: state\ndata: ${line}\n\n`); } catch { state.streams.delete(res); }
}
}
function teardown() {
if (!state) return;
if (state.listener) state.listener.stop();
clearInterval(state.pinger);
for (const res of state.streams) { try { res.end(); } catch { /* already gone */ } }
state.streams.clear();
state = null;
}
function listDevices() {
return new Promise((resolve, reject) => {
const { execFile } = require('child_process');
execFile(path.join(__dirname, 'spl-capture'), ['--list'], { timeout: 10000 },
(err, stdout) => {
if (err && !stdout) { reject(err); return; }
try { resolve(JSON.parse(stdout).devices || []); }
catch (e) { reject(e); }
});
});
}
function json(res, code, body) {
const text = JSON.stringify(body);
res.writeHead(code, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(text),
});
res.end(text);
}
+735
View File
@@ -0,0 +1,735 @@
// spl-capture — calibrated sound-level measurement from a CoreAudio input.
//
// Sibling of the Timers module's ltc-capture: same device enumeration, same
// "stdout is data, stderr is talk" contract, same one-file-no-dependencies
// rule. The difference is where the work happens. LTC ships raw samples to
// Node because the decode is cheap and single-channel; sound level is
// filtering and FFTs over every channel at the device's rate, so the
// measurement is done here and only the answers cross the pipe.
//
// Everything below uses Accelerate, which ships with macOS. No third-party
// code, no package manager, ~2 s to build:
//
// swiftc -O -o modules/spl-meter/spl-capture \
// modules/spl-meter/spl-capture.swift
//
// spl-capture --list
// every input-capable device: name, input channels, sample rate
// spl-capture --device "Dante Via 16 Channel" --channels 1,2 [--cal 0,0]
// measures those 1-based channels and writes one JSON line per frame
// to stdout, 20×/s. --cal takes one dB offset per channel, in the same
// order, from the calibration route (94 dB reference minus what we
// measured). With no --cal the numbers are dBFS-relative and the line
// says calibrated:false, so nothing downstream can mistake them for
// real SPL.
//
// Per channel, per frame: the level at EVERY averaging a dashboard might
// ask for — fast (125 ms), slow (1 s), inf (Leq since reset) and none (this
// block alone) — plus rolling 1- and 10-minute Leq, C- and Z-weighted peak,
// raw dBFS and a clip flag. Optionally a 1/48-octave band set for the RTA
// and spectrogram tiles, only when --bands is passed.
//
// Everything is sent every frame on purpose. Two dashboards watching the
// same room may want different averaging and different banding, and neither
// choice may disturb the other or restart capture. So the measurement is
// made once, at the finest resolution anyone asked for, and each tile folds
// it down to the view it wants. Nothing here is per-viewer state.
//
// Weighting filters are IEC 61672 pole/zero prototypes, bilinear-transformed
// with the top section prewarped, then normalised to exactly 0 dB at 1 kHz.
// Verified against the standard's table: within 0.15 dB from 31.5 Hz to
// 4 kHz. The top octave is where the bilinear transform runs out of room —
// at 48 kHz the 16 kHz point sits about 3 dB low. That is a rounding error
// on a broadband programme number (A-weighting has already thrown 6.6 dB
// away up there) but it is a real limit, and the fix if it ever matters is
// to run the weighting at 96 kHz. `--selftest` prints the table so this
// claim can be checked rather than believed.
//
// macOS asks for microphone permission on first use, and the prompt belongs
// to whatever launched this — the ProdDash launcher, or Terminal. A server
// started over SSH gets SILENT ZEROS from every input with no error at all,
// which shows up downstream as a meter pinned at the floor.
import AVFoundation
import Accelerate
import CoreAudio
import Foundation
func fail(_ msg: String) -> Never {
FileHandle.standardError.write(("spl-capture: " + msg + "\n").data(using: .utf8)!)
exit(2)
}
func note(_ msg: String) {
FileHandle.standardError.write(("spl-capture: " + msg + "\n").data(using: .utf8)!)
}
/* ── CoreAudio device enumeration ───────────────────────────────────── */
struct InputDevice {
let id: AudioDeviceID
let name: String
let uid: String
let channels: Int
let sampleRate: Double
}
func stringProperty(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector) -> String {
var addr = AudioObjectPropertyAddress(
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var ref: Unmanaged<CFString>? = nil
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
let err = withUnsafeMutablePointer(to: &ref) {
AudioObjectGetPropertyData(id, &addr, 0, nil, &size, $0)
}
guard err == noErr, let value = ref?.takeRetainedValue() else { return "" }
return value as String
}
func sampleRateProperty(_ id: AudioObjectID) -> Double {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyNominalSampleRate,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var rate: Float64 = 0
var size = UInt32(MemoryLayout<Float64>.size)
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, &rate) == noErr else { return 0 }
return rate
}
func inputChannelCount(_ id: AudioDeviceID) -> Int {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: kAudioDevicePropertyScopeInput,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(id, &addr, 0, nil, &size) == noErr, size > 0 else { return 0 }
let listPtr = UnsafeMutableRawPointer.allocate(
byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
defer { listPtr.deallocate() }
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, listPtr) == noErr else { return 0 }
let buffers = UnsafeMutableAudioBufferListPointer(
listPtr.assumingMemoryBound(to: AudioBufferList.self))
return buffers.reduce(0) { $0 + Int($1.mNumberChannels) }
}
func listInputDevices() -> [InputDevice] {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(
AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size) == noErr else { return [] }
var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
guard AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &ids) == noErr else { return [] }
var out: [InputDevice] = []
for id in ids {
let channels = inputChannelCount(id)
if channels == 0 { continue }
out.append(InputDevice(id: id,
name: stringProperty(id, kAudioObjectPropertyName),
uid: stringProperty(id, kAudioDevicePropertyDeviceUID),
channels: channels,
sampleRate: sampleRateProperty(id)))
}
return out
}
/* ── frequency weighting (IEC 61672) ────────────────────────────────── */
/// Pole frequencies from the standard.
private let wf1 = 2 * Double.pi * 20.598997
private let wf2 = 2 * Double.pi * 107.65265
private let wf3 = 2 * Double.pi * 737.86223
private let wf4 = 2 * Double.pi * 12194.217
/// Bilinear-transform one analogue biquad into [b0, b1, b2, a1, a2].
///
/// `warpAt` prewarps the transform so that section's critical frequency
/// lands where the analogue prototype put it. Without it the plain
/// `c = 2·fs` mapping squeezes the top octave badly — it is worth about
/// 3 dB at 16 kHz for the 12.2 kHz pole pair.
func bilinear(_ b: (Double, Double, Double), _ a: (Double, Double, Double),
fs: Double, warpAt: Double = 0) -> [Double] {
let c = warpAt > 0 ? warpAt / tan(warpAt / (2 * fs)) : 2 * fs
let c2 = c * c
let B0 = b.0 * c2 + b.1 * c + b.2
let B1 = 2 * b.2 - 2 * b.0 * c2
let B2 = b.0 * c2 - b.1 * c + b.2
let A0 = a.0 * c2 + a.1 * c + a.2
let A1 = 2 * a.2 - 2 * a.0 * c2
let A2 = a.0 * c2 - a.1 * c + a.2
return [B0 / A0, B1 / A0, B2 / A0, A1 / A0, A2 / A0]
}
/// |H(f)| of a cascade of biquad sections, used to normalise gain at 1 kHz
/// and to print the verification table.
func cascadeResponse(_ secs: [Double], _ f: Double, fs: Double) -> Double {
let th = 2 * Double.pi * f / fs
var reT = 1.0, imT = 0.0
for s in stride(from: 0, to: secs.count, by: 5) {
let (b0, b1, b2, a1, a2) = (secs[s], secs[s+1], secs[s+2], secs[s+3], secs[s+4])
let nr = b0 + b1 * cos(th) + b2 * cos(2 * th)
let ni = -(b1 * sin(th) + b2 * sin(2 * th))
let dr = 1 + a1 * cos(th) + a2 * cos(2 * th)
let di = -(a1 * sin(th) + a2 * sin(2 * th))
let dd = dr * dr + di * di
guard dd > 0 else { return 0 }
let qr = (nr * dr + ni * di) / dd, qi = (ni * dr - nr * di) / dd
let t = reT * qr - imT * qi
imT = reT * qi + imT * qr
reT = t
}
return (reT * reT + imT * imT).squareRoot()
}
enum Weighting: String {
case a = "A", c = "C", z = "Z"
/// Biquad sections for this weighting at `fs`, normalised to 0 dB at 1 kHz.
func sections(fs: Double) -> [Double] {
var s: [Double]
switch self {
case .a:
s = bilinear((1, 0, 0), (1, 2 * wf1, wf1 * wf1), fs: fs)
+ bilinear((1, 0, 0), (1, wf2 + wf3, wf2 * wf3), fs: fs)
+ bilinear((0, 0, 1), (1, 2 * wf4, wf4 * wf4), fs: fs, warpAt: wf4)
case .c:
s = bilinear((1, 0, 0), (1, 2 * wf1, wf1 * wf1), fs: fs)
+ bilinear((0, 0, 1), (1, 2 * wf4, wf4 * wf4), fs: fs, warpAt: wf4)
case .z:
return [1, 0, 0, 0, 0] // flat, one pass-through section
}
let g = 1.0 / cascadeResponse(s, 1000.0, fs: fs)
s[0] *= g; s[1] *= g; s[2] *= g
return s
}
}
/// A weighting filter bound to one channel. The coefficients could be shared
/// but the delay line cannot, and a setup per channel costs a few hundred
/// bytes — not worth the aliasing risk of sharing one.
final class WeightingFilter {
private let setup: vDSP_biquad_Setup
private var delay: [Float]
private let sections: vDSP_Length
init(_ weighting: Weighting, fs: Double) {
let coeffs = weighting.sections(fs: fs)
sections = vDSP_Length(coeffs.count / 5)
guard let s = vDSP_biquad_CreateSetup(coeffs, sections) else {
fail("cannot build \(weighting.rawValue)-weighting filter")
}
setup = s
delay = [Float](repeating: 0, count: Int(sections) * 2 + 2)
}
deinit { vDSP_biquad_DestroySetup(setup) }
func process(_ input: UnsafePointer<Float>, _ output: inout [Float], _ n: Int) {
vDSP_biquad(setup, &delay, input, 1, &output, 1, vDSP_Length(n))
}
}
/* ── third-octave band plan for the RTA ─────────────────────────────── */
/// The wire always carries 1/48-octave bands, and a tile folds them down to
/// whatever banding it wants to draw.
///
/// That works exactly because every banding the dashboard offers divides 48:
/// 1/3 is sixteen of these summed, 1/6 is eight, 1/12 four, 1/24 two. Powers
/// are additive, so a fold is a sum and nothing is approximated. The
/// alternative — the capture re-banding on request — would mean one dashboard
/// choosing 1/6 changes what every other dashboard sees.
///
/// Band n is centred on 1000 · 2^(n/48); n = 0 is 1 kHz, which is what keeps
/// the folded centres on the standard values.
let bandN0 = -271 // ≈ 20 Hz
let bandN1 = 207 // ≈ 20 kHz
let bandCentres: [Double] = (bandN0...bandN1).map { 1000 * pow(2, Double($0) / 48) }
/// FFT bin ranges per band, clamped to what the transform can actually
/// resolve.
///
/// This is the one caveat a reader of the RTA needs and the gear is not
/// allowed to carry (module rule 5, no explanatory text in a tile's
/// settings): below roughly 200 Hz a 1/48-octave band is narrower than one
/// bin, so neighbouring bands start sharing bins and the low end reads
/// smoother than it really is. That is the window length talking, not the
/// room. Folding to 1/6 or 1/3 puts it right, and a multi-resolution
/// transform is the real fix if the bottom octaves ever have to be read at
/// full resolution.
func bandBins(fs: Double, fftSize: Int) -> [(Int, Int)] {
let binHz = fs / Double(fftSize)
let maxBin = fftSize / 2 - 1
return bandCentres.map { fc in
let lo = Int((fc * pow(2, -1.0 / 96.0) / binHz).rounded())
let hi = Int((fc * pow(2, 1.0 / 96.0) / binHz).rounded())
return (max(1, min(lo, maxBin)), max(1, min(max(hi, lo), maxBin)))
}
}
/* ── per-channel measurement state ──────────────────────────────────── */
/// Everything the standard asks for on one channel.
///
/// SPL is an exponential moving average of weighted mean square — Fast is a
/// 125 ms time constant, Slow 1 s, exactly as IEC 61672 defines them. Leq is
/// a plain energy average with no time constant at all, which is why it is
/// accumulated separately rather than read off the integrator.
final class ChannelMeter {
let index: Int // 1-based, as the operator counts them
private let fs: Double
private let calibration: Double // dB added to turn dBFS into dB SPL
private let weightFilter: WeightingFilter
private let cFilter: WeightingFilter
private var weighted: [Float]
private var cWeighted: [Float]
// One integrator per averaging the dashboard might ask for. They all run
// all the time and all go on the wire: a tile choosing "Slow" must not
// restart capture, because the next tile wants "Fast" off the same
// stream and a third wants Inf. Four extra doubles per channel is a
// rounding error next to the filtering; re-measuring per viewer is not.
private var msFast: Double = 0
private var msSlow: Double = 0
private var msNone: Double = 0 // this block only, no time constant
private var flatMeanSquare: Double = 0 // unweighted, Fast — for dBFS
private let alphaFast: Double
private let alphaSlow: Double
// Leq accumulators: running since reset, plus rolling 1 and 10 minute.
private var leqSum: Double = 0
private var leqCount: Double = 0
private var ring: [Double] = [] // one mean-square per emitted frame
private let ringCapacity: Int
private var peakC: Double = 0 // linear, since reset
private var peakZ: Double = 0
private var clipped = false
init(index: Int, fs: Double, weighting: Weighting, calibration: Double,
framesPerSecond: Double) {
self.index = index
self.fs = fs
self.calibration = calibration
weightFilter = WeightingFilter(weighting, fs: fs)
cFilter = WeightingFilter(.c, fs: fs)
weighted = []
cWeighted = []
// The time constants IEC 61672 names: Fast is 125 ms, Slow is 1 s.
alphaFast = 1.0 - exp(-1.0 / (0.125 * fs))
alphaSlow = 1.0 - exp(-1.0 / (1.0 * fs))
ringCapacity = Int((600.0 * framesPerSecond).rounded()) // 10 minutes
}
/// Feed one tap buffer for this channel.
func push(_ samples: UnsafePointer<Float>, _ n: Int) {
if weighted.count < n {
weighted = [Float](repeating: 0, count: n)
cWeighted = [Float](repeating: 0, count: n)
}
weightFilter.process(samples, &weighted, n)
cFilter.process(samples, &cWeighted, n)
// Exponential integration over the weighted signal, sample by sample
// in the power domain. vDSP has no one-pole on squares, and at 48 kHz
// this is a few hundred thousand multiply-adds a second — noise next
// to the filtering above.
var fast = msFast, slow = msSlow, flat = flatMeanSquare
weighted.withUnsafeBufferPointer { w in
for i in 0..<n {
let v = Double(w[i]) * Double(w[i])
fast += alphaFast * (v - fast)
slow += alphaSlow * (v - slow)
let r = Double(samples[i])
flat += alphaFast * (r * r - flat)
}
}
msFast = fast; msSlow = slow; flatMeanSquare = flat
// Mean square of just this block — "None", the reading with no time
// constant at all. Also what Leq accumulates, since Leq is a plain
// energy average and has no time constant either.
var sumSq: Float = 0
weighted.withUnsafeBufferPointer { w in
vDSP_measqv(w.baseAddress!, 1, &sumSq, vDSP_Length(n))
}
msNone = Double(sumSq)
leqSum += Double(sumSq) * Double(n)
leqCount += Double(n)
var cPeak: Float = 0, zPeak: Float = 0
cWeighted.withUnsafeBufferPointer { c in
vDSP_maxmgv(c.baseAddress!, 1, &cPeak, vDSP_Length(n))
}
vDSP_maxmgv(samples, 1, &zPeak, vDSP_Length(n))
peakC = max(peakC, Double(cPeak))
peakZ = max(peakZ, Double(zPeak))
if zPeak >= 0.999 { clipped = true }
}
/// dB SPL from a linear mean-square, with the calibration offset applied.
private func db(_ power: Double) -> Double {
20 * log10(max(power, 1e-20).squareRoot()) + calibration
}
/// Called once per emitted frame; also advances the rolling Leq window.
///
/// Returns a JSON object built by hand. JSONSerialization renders a
/// rounded Double as `-70.120000000000005`, which is both ugly on the
/// wire and noise in a diff — `%.2f` says what a meter can actually
/// resolve and nothing more.
func snapshot() -> String {
ring.append(msNone)
if ring.count > ringCapacity { ring.removeFirst(ring.count - ringCapacity) }
let fps = Double(ringCapacity) / 600.0
func rollingLeq(seconds: Double) -> Double {
let want = max(1, Int((seconds * fps).rounded()))
let slice = ring.suffix(want)
guard !slice.isEmpty else { return -200 }
return db(slice.reduce(0, +) / Double(slice.count))
}
return "{\"i\":\(index)"
+ f("fast", db(msFast))
+ f("slow", db(msSlow))
+ f("inf", leqCount > 0 ? db(leqSum / leqCount) : -200)
+ f("none", db(msNone))
+ f("leq1m", rollingLeq(seconds: 60))
+ f("leq10m", rollingLeq(seconds: 600))
+ f("peakC", db(peakC * peakC))
+ f("peakZ", db(peakZ * peakZ))
+ f("dbfs", 20 * log10(max(flatMeanSquare, 1e-20).squareRoot()))
+ ",\"clip\":\(clipped)}"
}
/// Clears Leq, peaks and the clip flag. The exponential integrator is
/// deliberately left alone — it is a live reading, not an accumulation,
/// and zeroing it would just make the meter dive for a moment.
func reset() {
leqSum = 0; leqCount = 0; ring.removeAll(keepingCapacity: true)
peakC = 0; peakZ = 0; clipped = false
}
}
/// `,"key":-12.34` — two decimals, and a floor instead of -inf or NaN so a
/// silent input can never put a non-number on the wire.
func f(_ key: String, _ v: Double) -> String {
",\"\(key)\":" + num(v)
}
func num(_ v: Double) -> String {
guard v.isFinite else { return "-200.00" }
return String(format: "%.2f", max(v, -200))
}
/// One decimal, for band arrays. 479 bands per channel per frame is the
/// bulk of the wire; a second decimal on a bar nobody can read that finely
/// is 15% of the bandwidth for nothing.
func num1(_ v: Double) -> String {
guard v.isFinite else { return "-200" }
return String(format: "%.1f", max(v, -200))
}
/* ── spectrum for the RTA / spectrograph tiles ──────────────────────── */
final class Spectrum {
private let fftSize: Int
private let log2n: vDSP_Length
private let setup: FFTSetup
private var window: [Float]
private var accum: [Float] // sliding input buffer
private var filled = 0
private let bins: [(Int, Int)]
private let windowPower: Double
init(fs: Double, fftSize: Int) {
self.fftSize = fftSize
log2n = vDSP_Length(log2(Double(fftSize)).rounded())
guard let s = vDSP_create_fftsetup(log2n, FFTRadix(kFFTRadix2)) else {
fail("cannot create FFT setup")
}
setup = s
window = [Float](repeating: 0, count: fftSize)
vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_DENORM))
accum = [Float](repeating: 0, count: fftSize)
bins = bandBins(fs: fs, fftSize: fftSize)
// Coherent gain of the Hann window, so band levels are not 6 dB low.
windowPower = window.reduce(0.0) { $0 + Double($1) * Double($1) } / Double(fftSize)
}
deinit { vDSP_destroy_fftsetup(setup) }
/// Keep the most recent `fftSize` samples, shifting the window along.
/// Buffers larger than the window just overwrite it outright.
func push(_ samples: UnsafePointer<Float>, _ n: Int) {
if n >= fftSize {
accum.withUnsafeMutableBufferPointer { a in
a.baseAddress!.update(from: samples + (n - fftSize), count: fftSize)
}
filled = fftSize
return
}
accum.withUnsafeMutableBufferPointer { a in
let base = a.baseAddress!
// Slide the old tail down, then append the new block.
base.update(from: base + n, count: fftSize - n)
(base + fftSize - n).update(from: samples, count: n)
}
filled = min(fftSize, filled + n)
}
/// Third-octave band levels in dB relative to full scale, plus the
/// calibration offset applied by the caller.
func bands(calibration: Double) -> [Double] {
guard filled >= fftSize else { return bins.map { _ in -200 } }
var windowed = [Float](repeating: 0, count: fftSize)
vDSP_vmul(accum, 1, window, 1, &windowed, 1, vDSP_Length(fftSize))
let half = fftSize / 2
var re = [Float](repeating: 0, count: half)
var im = [Float](repeating: 0, count: half)
var mag = [Float](repeating: 0, count: half)
re.withUnsafeMutableBufferPointer { rp in
im.withUnsafeMutableBufferPointer { ip in
var split = DSPSplitComplex(realp: rp.baseAddress!, imagp: ip.baseAddress!)
windowed.withUnsafeBytes { raw in
vDSP_ctoz(raw.bindMemory(to: DSPComplex.self).baseAddress!, 2,
&split, 1, vDSP_Length(half))
}
vDSP_fft_zrip(setup, &split, 1, log2n, FFTDirection(FFT_FORWARD))
vDSP_zvmags(&split, 1, &mag, 1, vDSP_Length(half))
}
}
// vDSP's real FFT carries a factor of 2, and zvmags squares it.
let scale = 1.0 / (4.0 * Double(fftSize) * Double(fftSize) * windowPower)
return bins.map { (lo, hi) in
var power = 0.0
for b in lo...hi { power += Double(mag[b]) }
power *= scale * 2.0 // one-sided spectrum
return 10 * log10(max(power, 1e-20)) + calibration
}
}
}
/* ── args ───────────────────────────────────────────────────────────── */
var wantList = false
var wantSelftest = false
var wantDevice: String? = nil
var wantChannels: [Int] = [1]
var wantCal: [Double] = []
var wantWeighting = Weighting.a
var wantBands = false
var framesPerSecond = 20.0
let argv = CommandLine.arguments
var argIdx = 1
func nextArg(_ flag: String) -> String {
argIdx += 1
guard argIdx < argv.count else { fail("\(flag) needs a value") }
return argv[argIdx]
}
while argIdx < argv.count {
switch argv[argIdx] {
case "--list": wantList = true
case "--selftest": wantSelftest = true
case "--device": wantDevice = nextArg("--device")
case "--channels":
wantChannels = nextArg("--channels").split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces)) }
if wantChannels.isEmpty { fail("--channels needs at least one 1-based channel number") }
case "--cal":
wantCal = nextArg("--cal").split(separator: ",").compactMap { Double($0.trimmingCharacters(in: .whitespaces)) }
case "--weighting":
let raw = nextArg("--weighting").uppercased()
guard let w = Weighting(rawValue: raw) else { fail("--weighting must be A, C or Z") }
wantWeighting = w
case "--bands": wantBands = true
case "--rate":
guard let r = Double(nextArg("--rate")), r >= 1, r <= 50 else { fail("--rate must be 1-50 (frames per second)") }
framesPerSecond = r
default: fail("unknown flag \(argv[argIdx]) — try --list")
}
argIdx += 1
}
/* ── selftest: prove the weighting curves before trusting a reading ─── */
if wantSelftest {
// IEC 61672-1 table 3, nominal A- and C-weighting.
let table: [(Double, Double, Double)] = [
(31.5, -39.4, -3.0), (63, -26.2, -0.8), (125, -16.1, -0.2), (250, -8.6, 0.0),
(500, -3.2, 0.0), (1000, 0.0, 0.0), (2000, 1.2, -0.2), (4000, 1.0, -0.8),
(8000, -1.1, -3.0), (16000, -6.6, -8.5),
]
for fs in [48000.0, 96000.0] {
let a = Weighting.a.sections(fs: fs), c = Weighting.c.sections(fs: fs)
print("— \(Int(fs)) Hz —")
print(" Hz A IEC err C IEC err")
var worstA = 0.0, worstC = 0.0
for (f, wantA, wantC) in table where f < fs / 2 {
let gotA = 20 * log10(cascadeResponse(a, f, fs: fs))
let gotC = 20 * log10(cascadeResponse(c, f, fs: fs))
worstA = max(worstA, abs(gotA - wantA)); worstC = max(worstC, abs(gotC - wantC))
print(String(format: "%7.0f %+7.2f %+7.2f %+6.2f %+7.2f %+7.2f %+6.2f",
f, gotA, wantA, gotA - wantA, gotC, wantC, gotC - wantC))
}
print(String(format: " worst: A %.2f dB, C %.2f dB\n", worstA, worstC))
}
exit(0)
}
/* ── device selection ───────────────────────────────────────────────── */
let devices = listInputDevices()
if wantList || wantDevice == nil {
if devices.isEmpty { fail("no input-capable audio devices found") }
// stdout, as JSON: the module's /devices route feeds this straight to
// the admin page's device picker.
let payload = devices.map { ["name": $0.name, "uid": $0.uid,
"channels": $0.channels, "sampleRate": $0.sampleRate] }
let data = try! JSONSerialization.data(withJSONObject: ["devices": payload])
FileHandle.standardOutput.write(data)
FileHandle.standardOutput.write("\n".data(using: .utf8)!)
if wantDevice == nil && !wantList { note("pick one with --device <name substring>") }
exit(wantList ? 0 : 2)
}
let query = wantDevice!.lowercased()
let exact = devices.filter { $0.name.lowercased() == query || $0.uid.lowercased() == query }
let matches = exact.count == 1 ? exact
: devices.filter { $0.name.lowercased().contains(query) || $0.uid.lowercased().contains(query) }
guard matches.count == 1 else {
if matches.isEmpty { fail("no input device matches \"\(wantDevice!)\" — see --list") }
fail("\"\(wantDevice!)\" is ambiguous: " + matches.map { $0.name }.joined(separator: ", "))
}
let device = matches[0]
/* ── capture ────────────────────────────────────────────────────────── */
let engine = AVAudioEngine()
guard let unit = engine.inputNode.audioUnit else { fail("no input audio unit") }
var deviceID = device.id
let setErr = AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice,
kAudioUnitScope_Global, 0, &deviceID,
UInt32(MemoryLayout<AudioDeviceID>.size))
guard setErr == noErr else { fail("cannot select \"\(device.name)\" (error \(setErr))") }
let format = engine.inputNode.inputFormat(forBus: 0)
let channelCount = Int(format.channelCount)
let sampleRate = format.sampleRate
guard channelCount > 0, sampleRate > 0 else {
fail("\"\(device.name)\" reports no usable input format — microphone access may be denied "
+ "(System Settings → Privacy & Security → Microphone)")
}
for ch in wantChannels where ch < 1 || ch > channelCount {
fail("channel \(ch) is out of range — \"\(device.name)\" has \(channelCount)")
}
let calibrated = !wantCal.isEmpty
if calibrated && wantCal.count != wantChannels.count {
fail("--cal needs one offset per channel (\(wantChannels.count) given \(wantCal.count))")
}
let meters = wantChannels.enumerated().map { (i, ch) in
ChannelMeter(index: ch, fs: sampleRate, weighting: wantWeighting,
calibration: calibrated ? wantCal[i] : 0, framesPerSecond: framesPerSecond)
}
let spectra = wantBands ? wantChannels.map { _ in Spectrum(fs: sampleRate, fftSize: 16384) } : []
note("capturing \"\(device.name)\": \(channelCount) channels @ \(Int(sampleRate)) Hz, "
+ "measuring \(wantChannels.map(String.init).joined(separator: ",")) "
+ "\(wantWeighting.rawValue)-weighted"
+ (calibrated ? "" : ", UNCALIBRATED"))
// Announced once so the reader knows the band plan without hardcoding it.
let hello: [String: Any] = [
"hello": 1,
"device": device.name,
"sampleRate": sampleRate,
"deviceChannels": channelCount,
"channels": wantChannels,
"weighting": wantWeighting.rawValue,
"calibrated": calibrated,
// Centres and the index of the 1 kHz band, so a tile can fold to any
// banding without hardcoding the plan.
"bands": wantBands ? bandCentres.map { round($0 * 100) / 100 } : [],
"bandN0": bandN0,
"bandsPerOctave": 48,
]
let stdoutHandle = FileHandle.standardOutput
func emitLine(_ json: String) {
do {
try stdoutHandle.write(contentsOf: Data((json + "\n").utf8))
} catch { exit(0) } // downstream pipe closed — nothing to say
}
if let data = try? JSONSerialization.data(withJSONObject: hello),
let line = String(data: data, encoding: .utf8) {
emitLine(line)
}
// A reset is asked for on stdin (one line, "reset") rather than a signal, so
// the listener can clear Leq and peaks without restarting capture and losing
// the filters' settling.
let resetQueue = DispatchQueue(label: "spl-capture.reset")
var resetRequested = false
DispatchQueue.global().async {
while let line = readLine(strippingNewline: true) {
if line.trimmingCharacters(in: .whitespaces).lowercased() == "reset" {
resetQueue.sync { resetRequested = true }
}
}
}
let tapQueue = DispatchQueue(label: "spl-capture.tap")
engine.inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { buffer, _ in
let frames = Int(buffer.frameLength)
guard frames > 0, let floats = buffer.floatChannelData else { return }
tapQueue.sync {
for (i, ch) in wantChannels.enumerated() {
let src = floats[ch - 1] // tap data is per-channel, not interleaved
meters[i].push(src, frames)
if wantBands { spectra[i].push(src, frames) }
}
}
}
do { try engine.start() } catch {
fail("engine start failed: \(error.localizedDescription) — if this is a permission problem, "
+ "approve microphone access for the launching app and retry")
}
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global())
timer.schedule(deadline: .now() + 1.0 / framesPerSecond,
repeating: 1.0 / framesPerSecond)
timer.setEventHandler {
var wantReset = false
resetQueue.sync { wantReset = resetRequested; resetRequested = false }
var line = "{\"t\":\(Int(Date().timeIntervalSince1970 * 1000))"
tapQueue.sync {
if wantReset { meters.forEach { $0.reset() } }
line += ",\"ch\":[" + meters.map { $0.snapshot() }.joined(separator: ",") + "]"
if wantBands {
let sets = spectra.enumerated().map { (i, s) -> String in
"[" + s.bands(calibration: calibrated ? wantCal[i] : 0)
.map(num1).joined(separator: ",") + "]"
}
line += ",\"bands\":[" + sets.joined(separator: ",") + "]"
}
}
emitLine(line + "}")
}
timer.resume()
signal(SIGINT) { _ in exit(0) }
signal(SIGTERM) { _ in exit(0) }
RunLoop.main.run()
+239
View File
@@ -0,0 +1,239 @@
'use strict';
/**
* Sound level listener — owns the native capture child.
*
* Spawns spl-capture (compiled from spl-capture.swift beside it) for the
* admin-configured device and channels, reads its JSON lines, and hands the
* latest frame upward. All measurement happens in the child; this file is
* plumbing and supervision, nothing more.
*
* Reports via onUpdate:
*
* { frame: {...}, meta: {...} } measuring
* { error: '<what is wrong and how to fix it>' } not measuring
*
* The capture binary is per-machine and gitignored, exactly like the Timers
* module's ltc-capture. It normally arrives already built — compiled beside
* the app, or copied from any Mac of the same architecture. When it is
* missing this will build it, but in the background and never on the event
* loop: see ensureBinary for what that cost the first time it was wrong.
*
* The same macOS gotcha applies as for LTC, and is worth repeating because
* it costs an afternoon every time: a ProdDash launched over SSH or from a
* bare launchd job gets SILENT ZEROS from every input, with no error
* anywhere. Here that shows up as a meter pinned at -200. Launch from a
* logged-in GUI context and approve the microphone prompt once.
*/
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const RESTART_MS = 5000;
/** An immediate exit is config-shaped (bad device, channel out of range) —
retrying every 5 s would only fill the log with the same sentence. */
const BAD_CONFIG_RETRY_MS = 30000;
/** No frame for this long means the child is alive but not delivering. */
const STALL_MS = 3000;
/**
* @param {{device:string, channels:number[], weighting:string,
* bands:boolean, calibration:number[], moduleDir:string,
* log:Function, onUpdate:(u:object)=>void}} opts
* @returns {{stop:()=>void, reset:()=>void}}
*/
function createSplListener(opts) {
const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts;
const bin = path.join(moduleDir, 'spl-capture');
let child = null;
let stopped = false;
let restartTimer = null;
let stallTimer = null;
let startedAt = 0;
let meta = null;
let building = false;
let buildFailed = false;
let lastFrameWall = 0;
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
+ 'and copy it next to spl-capture.swift';
/**
* Build the capture tool if it is missing, WITHOUT blocking.
*
* This used to be a spawnSync, copied from the LTC listener where the
* source is a third the size and builds in a few seconds. Here it took
* nearly two minutes on the production Mac and froze the entire ProdDash
* server for every second of it — one module's optional helper took the
* whole dashboard down, then did it again on every retry. Nothing that
* runs while a service might be on is allowed to hold the event loop.
*
* The build is a fallback for a module installed from the catalog on a
* machine with a Swift toolchain. The normal path is a binary built
* alongside the app, or copied from any same-architecture Mac.
*
* @returns {string} '' when the binary is ready, otherwise what to say.
*/
function ensureBinary() {
if (fs.existsSync(bin)) return '';
const src = bin + '.swift';
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
if (building) return 'Building the capture tool — this takes a minute or two';
if (buildFailed) return BUILD_HELP;
building = true;
log('spl-capture binary missing — building it in the background');
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
let buildErr = '';
build.stderr.setEncoding('utf8');
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
const giveUp = setTimeout(() => {
try { build.kill('SIGKILL'); } catch { /* already gone */ }
}, 300000);
build.on('error', (err) => {
clearTimeout(giveUp);
building = false; buildFailed = true;
log(`swiftc could not be run: ${err.message}`);
});
build.on('exit', (code) => {
clearTimeout(giveUp);
building = false;
if (code === 0 && fs.existsSync(bin)) {
log('spl-capture built');
buildFailed = false;
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
return;
}
buildFailed = true;
log(`spl-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
});
return 'Building the capture tool — this takes a minute or two';
}
function scheduleRestart(ms) {
if (stopped || restartTimer) return;
restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms);
}
function armStallWatch() {
clearTimeout(stallTimer);
stallTimer = setTimeout(() => {
if (stopped || !child) return;
if (Date.now() - lastFrameWall >= STALL_MS) {
log('no frames for 3 s — restarting capture');
try { child.kill('SIGTERM'); } catch { /* already gone */ }
}
}, STALL_MS);
}
function start() {
if (stopped) return;
const missing = ensureBinary();
if (missing) {
onUpdate({ error: missing });
// A build in flight calls start() itself when it lands; anything else
// is config-shaped and should back off hard rather than spin.
if (!building) scheduleRestart(BAD_CONFIG_RETRY_MS);
return;
}
// No averaging or banding flag: the child computes every averaging and
// the finest banding regardless, so tiles can differ without a restart.
const args = ['--device', device, '--channels', channels.join(','),
'--weighting', weighting, '--rate', '20'];
if (bands) args.push('--bands');
if (calibration.length === channels.length) args.push('--cal', calibration.join(','));
startedAt = Date.now();
meta = null;
let stderrTail = '';
let buf = '';
child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
child.stderr.setEncoding('utf8');
child.stderr.on('data', (text) => {
stderrTail = (stderrTail + text).slice(-500);
});
child.stdout.setEncoding('utf8');
child.stdout.on('data', (text) => {
buf += text;
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (!line) continue;
let obj;
try { obj = JSON.parse(line); } catch { continue; }
if (obj.hello) {
meta = obj;
log(`measuring ${obj.device} ch ${obj.channels.join(',')} `
+ `${obj.weighting}-weighted`
+ (obj.calibrated ? '' : ' (uncalibrated)'));
continue;
}
if (!obj.ch) continue;
lastFrameWall = Date.now();
armStallWatch();
onUpdate({ frame: obj, meta });
}
// A run of junk with no newline would grow forever; the child never
// emits a line this long, so anything that does is a bug upstream.
if (buf.length > 65536) buf = '';
});
child.on('error', (err) => {
onUpdate({ error: `cannot run spl-capture: ${err.message}` });
child = null;
scheduleRestart(BAD_CONFIG_RETRY_MS);
});
child.on('exit', (code, signal) => {
child = null;
clearTimeout(stallTimer);
if (stopped) return;
const ranFor = Date.now() - startedAt;
const why = stderrTail.trim().split('\n').pop() || `exit ${code}${signal ? ' ' + signal : ''}`;
// Exiting almost immediately means it never got as far as capturing —
// an unknown device, an out-of-range channel, a denied microphone.
// Those do not fix themselves, so back off hard and say why.
if (ranFor < 2000) {
onUpdate({ error: why });
scheduleRestart(BAD_CONFIG_RETRY_MS);
} else {
log(`capture exited after ${Math.round(ranFor / 1000)}s: ${why}`);
scheduleRestart(RESTART_MS);
}
});
armStallWatch();
}
start();
return {
/** Clear Leq and peak hold without restarting capture — the filters keep
their settling, so the live number never dips. */
reset() {
if (child && child.stdin.writable) {
try { child.stdin.write('reset\n'); } catch { /* child going away */ }
}
},
stop() {
stopped = true;
clearTimeout(restartTimer); restartTimer = null;
clearTimeout(stallTimer); stallTimer = null;
if (child) {
try { child.kill('SIGTERM'); } catch { /* already gone */ }
child = null;
}
},
};
}
module.exports = { createSplListener };
+148
View File
@@ -0,0 +1,148 @@
/* SPL Meter — every colour comes from the shell's variables so themes and
per-tile tints reach in. Nothing here is hardcoded except the canvas
heat ramp, which client.js owns because it has to interpolate. */
[data-module="spl-meter"] .spl {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ── readout ──────────────────────────────────────────────────────── */
[data-module="spl-meter"] .spl-readout {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25em;
min-height: 0;
padding: 0.25rem;
}
[data-module="spl-meter"] .spl-value {
display: flex;
align-items: baseline;
gap: 0.2em;
line-height: 1;
color: var(--text);
}
/* A first-paint size only. client.js measures the text and sets the real
one inline, because CSS has to assume a digit count and gets it wrong the
moment the reading needs a sixth character: the coefficient that fills a
tile for "-45.9" pushes "-100.5" straight out through the side. */
[data-module="spl-meter"] .spl-num {
font-size: clamp(1.25rem, min(26cqw, 60cqh), 100rem);
font-variant-numeric: tabular-nums;
font-weight: 600;
letter-spacing: -0.02em;
}
[data-module="spl-meter"] .spl-unit {
font-size: clamp(0.65rem, min(7cqw, 15cqh), 26rem);
color: var(--muted);
}
[data-module="spl-meter"] .spl-sub {
font-size: 0.7rem;
color: var(--muted);
text-align: center;
line-height: 1.3;
}
[data-module="spl-meter"] .spl-readout[data-level="warn"] .spl-num { color: var(--warn); }
[data-module="spl-meter"] .spl-readout[data-level="alarm"] .spl-num { color: var(--danger); }
[data-module="spl-meter"] .spl-readout[data-level="idle"] .spl-num { color: var(--muted); }
/* ── level bars ───────────────────────────────────────────────────── */
[data-module="spl-meter"] .spl-bars {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.35rem;
padding: 0.4rem;
min-height: 0;
overflow-y: auto;
}
[data-module="spl-meter"] .spl-bar {
display: grid;
grid-template-columns: 1.4em 1fr 3.4em;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
}
[data-module="spl-meter"] .spl-bar-label { color: var(--muted); text-align: right; }
[data-module="spl-meter"] .spl-bar-track {
position: relative;
height: 0.65rem;
border-radius: 0.35rem;
background: color-mix(in srgb, var(--border) 60%, transparent);
overflow: hidden;
}
[data-module="spl-meter"] .spl-bar-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0;
border-radius: inherit;
background: var(--accent);
transition: width 60ms linear;
}
[data-module="spl-meter"] .spl-bar[data-level="warn"] .spl-bar-fill { background: var(--warn); }
[data-module="spl-meter"] .spl-bar[data-level="alarm"] .spl-bar-fill { background: var(--danger); }
[data-module="spl-meter"] .spl-bar[data-level="idle"] .spl-bar-fill { background: var(--muted); }
/* Peak hold sits on the track rather than inside the fill, so it stays
visible when the bar falls away underneath it. */
[data-module="spl-meter"] .spl-bar-peak {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: var(--text);
opacity: 0.75;
}
[data-module="spl-meter"] .spl-bar-value {
font-variant-numeric: tabular-nums;
text-align: right;
color: var(--text);
}
[data-module="spl-meter"] .spl-bar.clip .spl-bar-value { color: var(--danger); }
/* ── canvas tiles ─────────────────────────────────────────────────── */
[data-module="spl-meter"] .spl-canvas {
flex: 1;
display: block;
width: 100%;
min-height: 0;
border-radius: 0.25rem;
}
/* The readout scales off both of the tile's dimensions, so the container
has to be sized on both axes — inline-size alone leaves cqh undefined. */
[data-module="spl-meter"] .spl { container-type: size; }
/* The sub-line is the first thing to go when the tile gets short: the
number is the point, and a clipped caption under it is worse than none. */
[data-module="spl-meter"] .spl-sub {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@container (max-height: 90px) {
[data-module="spl-meter"] .spl-sub { display: none; }
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "proddash",
"version": "1.3.0",
"version": "1.8.0",
"private": true,
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client",
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED",
"engines": {
"node": ">=18"
+1 -1
View File
@@ -38,7 +38,7 @@ Edit [config.json](config.json):
| Key | Meaning |
| --- | --- |
| `prodcomUrl` | Where the ProdCom API lives (default `http://10.3.11.152:24480`) |
| `prodcomUrl` | Where the ProdCom API lives — set it per site, e.g. `http://<prodcom-host>:24480` |
| `port` | Port this listener serves on (default `24481`) |
| `apiKey` | Only needed if PSK auth is enabled in ProdCom settings |
+1 -1
View File
@@ -1,5 +1,5 @@
{
"prodcomUrl": "http://10.3.11.152:24480",
"prodcomUrl": "",
"port": 24481,
"apiKey": ""
}
+6 -1
View File
@@ -23,10 +23,15 @@ try {
console.warn('config.json not found or invalid, using defaults/env vars');
}
const PRODCOM_URL = process.env.PRODCOM_URL || cfg.prodcomUrl || 'http://10.3.11.152:24480';
const PRODCOM_URL = process.env.PRODCOM_URL || cfg.prodcomUrl || '';
const PORT = Number(process.env.PORT || cfg.port || 24481);
const API_KEY = process.env.PRODCOM_API_KEY || cfg.apiKey || '';
if (!PRODCOM_URL) {
console.error('No ProdCom API configured. Set prodcomUrl in config.json (or the PRODCOM_URL');
console.error('environment variable) to this site\'s ProdCom, e.g. http://<prodcom-host>:24480');
process.exit(1);
}
const target = new URL(PRODCOM_URL);
const PUBLIC_DIR = path.join(__dirname, 'public');
+274 -6
View File
@@ -94,6 +94,35 @@ body { overflow: auto; }
}
.module-health.error { color: var(--danger); }
/* a module card's own status line ("polling timers on 10.3.11.20:1025",
"no ProPresenter host configured"): a readout, so it sits in a bordered
monospace box a shade lighter than the card; ok / connecting / error
show as the text (and border) colour, the box itself never changes */
.module-status {
display: inline-block;
max-width: 100%;
margin-top: 8px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: color-mix(in srgb, var(--text) 5%, var(--panel));
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 11.5px;
line-height: 1.45;
color: var(--muted);
overflow-wrap: anywhere;
}
.module-status:empty { display: none; }
.module-status.ok { color: var(--text); }
.module-status.connecting {
color: var(--warn);
border-color: color-mix(in srgb, var(--warn) 40%, var(--border));
}
.module-status.error {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 40%, var(--border));
}
.module-mount-error {
margin-top: 8px;
padding: 8px 10px;
@@ -106,11 +135,33 @@ body { overflow: auto; }
.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
.module-form .field { max-width: 420px; }
/* a multi-line string (manifest type `text`): the same box as the other
inputs (those rules live in the shell's style.css), a few lines tall */
.field textarea {
padding: 7px 9px;
font-size: 13px;
font-family: inherit;
line-height: 1.4;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 7px;
resize: vertical;
min-height: 72px;
}
.field textarea:focus { outline: none; border-color: var(--accent); }
/* endpoint fields: every module's upstream is entered the same way */
.field .endpoint-row { display: flex; align-items: center; gap: 7px; }
.endpoint-row .ep-host { flex: 1; min-width: 0; }
.endpoint-row .ep-port { width: 92px; flex: none; }
.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; }
/* several picks from a list (manifest type `multiselect`) */
.field.multiselect-field { cursor: default; }
.field.multiselect-field .multiselect { display: flex; flex-direction: column; gap: 6px; }
.multiselect-row { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.multiselect-empty { color: var(--muted); font-size: 12px; }
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
/* labeled config subsections (manifest field `group`) */
@@ -121,6 +172,10 @@ body { overflow: auto; }
border-radius: 9px;
}
.field-group-title {
display: flex;
align-items: center;
gap: 7px;
position: relative; /* the ⓘ popover hangs off this row */
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
@@ -128,8 +183,82 @@ body { overflow: auto; }
color: var(--muted);
margin-bottom: 10px;
}
.field-group-name { min-width: 0; }
.field-group .field:last-child { margin-bottom: 0; }
/* group help (configGroups[name].help): an ⓘ beside the title. Hover or
focus peeks at the text; a click pins it open until the next click. */
.group-info { display: inline-flex; align-items: center; }
.group-info-btn {
width: 16px;
height: 16px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--muted);
border-radius: 50%;
background: transparent;
color: var(--muted);
font: italic 700 10px/1 ui-serif, Georgia, "Times New Roman", serif;
text-transform: none;
letter-spacing: 0;
cursor: help;
}
.group-info-btn:hover,
.group-info.is-open .group-info-btn { color: var(--text); border-color: var(--text); }
.group-info-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.group-info-pop {
display: none;
position: absolute;
left: 0;
top: calc(100% + 6px);
z-index: 5;
max-width: min(440px, 100%);
padding: 8px 10px;
background: color-mix(in srgb, var(--text) 6%, var(--panel));
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
color: var(--text);
font-size: 12px;
font-weight: 400;
line-height: 1.5;
letter-spacing: normal;
text-transform: none;
white-space: normal;
cursor: auto;
}
/* keeps the popover open while the pointer crosses the gap down to it */
.group-info-pop::before { content: ""; position: absolute; left: 0; right: 0; top: -8px; height: 8px; }
.group-info:hover .group-info-pop,
.group-info-btn:focus-visible + .group-info-pop,
.group-info.is-open .group-info-pop { display: block; }
/* group toggle (configGroups[name].toggle): the named switch sits at the
right of the title row as the group's on/off; the body dims while it is
off but stays editable */
.field-group-toggle {
margin-left: auto;
display: inline-flex;
align-items: center;
flex: none;
}
.field-group-toggle .field {
margin: 0;
max-width: none;
font-size: 12px;
font-weight: 600;
letter-spacing: normal;
text-transform: none;
color: var(--text);
}
.field-group-toggle .field.switch-field { flex-direction: row-reverse; gap: 8px; }
.field-group-toggle .field.switch-field .switch-label { font-size: 12px; }
.field-group-toggle .field.check { flex-direction: row-reverse; gap: 8px; }
.field-group.is-off > .field-group-body { opacity: 0.45; transition: opacity 0.15s; }
.field-group.has-toggle > .field-group-body { transition: opacity 0.15s; }
/* collapsible groups (manifest configGroups[name].collapsed) */
details.field-group > summary.field-group-title {
cursor: pointer;
@@ -160,18 +289,16 @@ details.field-group > summary.field-group-title:hover { color: var(--text); }
gap: 4px 16px;
}
.field-group-body.is-columns .field { margin-bottom: 4px; max-width: none; }
.field-group-body.is-columns .field-group-help { grid-column: 1 / -1; }
@media (max-width: 560px) { .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } }
.field-group-help,
/* one line of guidance under a control (field `help`) */
.field-help {
display: block;
font-size: 11.5px;
line-height: 1.45;
color: var(--muted);
margin-top: 2px;
}
.field-group-help { margin: -4px 0 8px; }
.field-help { margin-top: 2px; }
.field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
/* showWhen: a hidden dependent field must actually collapse — `.field`'s
@@ -190,12 +317,19 @@ details.field-group > summary.field-group-title:hover { color: var(--text); }
}
.field.switch-field .switch-label { font-size: 13px; }
/* the card's one action row: status on the left, Configure / Apply on the right */
.module-actions {
display: flex;
align-items: center;
align-items: flex-end; /* the button stays bottom-right when a readout wraps */
gap: 10px;
margin-top: 4px;
margin-top: 10px;
}
.module-name.is-toggle { cursor: pointer; }
.module-name.is-toggle:hover,
.module-name.is-toggle:focus-visible { color: var(--accent); outline: none; }
.module-actions .admin-status { flex: 1; margin-top: 0; }
.module-actions .module-status { flex: 0 1 auto; min-width: 0; margin-top: 0; }
.module-actions .btn { flex: none; margin-left: auto; }
.admin-status { min-height: 16px; font-size: 12px; color: var(--muted); margin-top: 8px; }
.admin-status.error { color: var(--danger); }
@@ -312,3 +446,137 @@ a.btn { text-decoration: none; }
.catalog-title { display: flex; align-items: center; gap: 8px; }
.catalog-row .module-desc { margin: 4px 0 2px; }
.catalog-row .module-health { margin-top: 2px; }
/* ── theme ──────────────────────────────────────────────────────────── */
/* one swatch per preset: a miniature dashboard (page, a tile on it, the
accent as a status dot) and the name; the current one wears the accent */
/* ── a collapsible admin section ─────────────────────────────────────
The pattern for any section of this page that folds. The markup is a
<details class="admin-fold"> holding the section's contents, headed by a
<summary class="admin-h1 admin-fold-title">: the title keeps the same
type as every other section heading — a <summary> would otherwise come
out at the normal weight — and the caret follows the words, so a folded
section still reads as a heading first. The whole row is the hit target. */
details.admin-fold > summary.admin-fold-title {
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: 8px;
font-weight: 700; /* matches h1.admin-h1; summary defaults to normal */
user-select: none;
}
details.admin-fold > summary.admin-fold-title::-webkit-details-marker { display: none; }
/* after the title, not before it — the heading reads first, the caret says
there is more behind it */
details.admin-fold > summary.admin-fold-title::after {
content: "";
width: 0; height: 0;
border-left: 6px solid var(--muted);
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
transition: transform 0.15s;
}
details.admin-fold[open] > summary.admin-fold-title::after { transform: rotate(90deg); }
details.admin-fold > summary.admin-fold-title:hover { color: var(--accent); }
.theme-swatches {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
gap: 10px;
margin-bottom: 6px;
}
.theme-swatch {
display: flex;
flex-direction: column;
gap: 7px;
padding: 8px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text);
font: inherit;
font-size: 12px;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.theme-swatch:hover { border-color: var(--muted); }
.theme-swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.theme-swatch.is-current { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.theme-preview {
position: relative;
display: block;
height: 46px;
border-radius: 6px;
background: var(--sw-bg);
border: 1px solid color-mix(in srgb, var(--sw-panel) 60%, var(--sw-accent));
overflow: hidden;
}
.theme-preview::before {
content: "";
position: absolute;
left: 8px; right: 8px; top: 10px; bottom: 8px;
border-radius: 4px;
background: var(--sw-panel);
border: 1px solid color-mix(in srgb, var(--sw-panel) 75%, var(--sw-accent));
}
.theme-preview::after {
content: "";
position: absolute;
left: 14px; top: 16px;
width: 8px; height: 8px;
border-radius: 50%;
background: var(--sw-accent);
box-shadow: 0 0 6px var(--sw-accent);
}
.theme-name { display: flex; align-items: center; gap: 6px; }
.theme-swatch.is-current .theme-name::after {
content: "current";
margin-left: auto;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
}
#theme-status { margin-top: 4px; }
/* ── the admin PIN ── */
/* Digits, evenly spaced, so six of them read as a PIN rather than a word. */
.pin-input {
font-variant-numeric: tabular-nums;
letter-spacing: 0.35em;
text-align: center;
max-width: 9em;
}
.pin-form {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
}
.pin-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--muted);
}
.pin-field[hidden] { display: none; }
.pin-field input {
padding: 8px 10px;
font-size: 14px;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 7px;
}
.pin-field input:focus { outline: none; border-color: var(--accent); }
.pin-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
+469 -23
View File
@@ -2,8 +2,10 @@
Everything here is server-wide: module enable/disable and admin config
(written to config/modules.json, module re-inited on save), plus named
layout management. Guarded by the shared passcode when one is set. */
layout management. Behind the admin PIN when one is set — a shell setting of
its own, nothing to do with a module's PIN (Slack's send PIN, say). */
const securitySection = document.getElementById('security-section');
const loginCard = document.getElementById('login-card');
const loginStatus = document.getElementById('login-status');
const passcodeInput = document.getElementById('passcode');
@@ -22,6 +24,9 @@ const updateStatus = document.getElementById('update-status');
const updateBtn = document.getElementById('update-btn');
const updateChanges = document.getElementById('update-changes');
const shellVersionEl = document.getElementById('shell-version');
const themeSection = document.getElementById('theme-section');
const themeSwatches = document.getElementById('theme-swatches');
const themeStatus = document.getElementById('theme-status');
/** The shell version the page loaded with — the update flow watches it change. */
let shellVersion = '';
@@ -58,20 +63,31 @@ function showLogin() {
availableSection.hidden = true;
layoutsSection.hidden = true;
updateCard.hidden = true;
themeSection.hidden = true;
securitySection.hidden = true;
passcodeInput.value = '';
passcodeInput.focus();
}
let loggingIn = false;
async function login() {
if (loggingIn) return;
loggingIn = true;
loginStatus.textContent = '';
loginStatus.className = 'admin-status';
try {
await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ passcode: passcodeInput.value }) });
await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ pin: passcodeInput.value }) });
passcodeInput.value = '';
loginCard.hidden = true;
await loadState();
} catch (e) {
passcodeInput.value = '';
loginStatus.textContent = e.message;
loginStatus.className = 'admin-status error';
passcodeInput.focus();
} finally {
loggingIn = false;
}
}
@@ -79,6 +95,82 @@ document.getElementById('login-btn').addEventListener('click', login);
passcodeInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') login();
});
// Six digits is the whole PIN, so there is nothing left to press.
passcodeInput.addEventListener('input', () => {
passcodeInput.value = passcodeInput.value.replace(/\D/g, '').slice(0, 6);
if (passcodeInput.value.length === 6) login();
});
/* ── the admin PIN ───────────────────────────────────────────────────── */
const pinCurrentField = document.getElementById('pin-current-field');
const pinCurrent = document.getElementById('pin-current');
const pinNew = document.getElementById('pin-new');
const pinRepeat = document.getElementById('pin-repeat');
const pinSaveBtn = document.getElementById('pin-save');
const pinClearBtn = document.getElementById('pin-clear');
const pinStatus = document.getElementById('pin-status');
const pinStateEl = document.getElementById('pin-state');
for (const input of [pinCurrent, pinNew, pinRepeat]) {
input.addEventListener('input', () => {
input.value = input.value.replace(/\D/g, '').slice(0, 6);
});
}
/** Paint the section for "no PIN yet" or "one is set". */
function renderPin(state) {
const set = Boolean(state.pinSet);
const fromEnv = Boolean(state.pinFromEnv);
securitySection.hidden = false;
pinCurrentField.hidden = !set;
pinClearBtn.hidden = !set;
document.getElementById('pin-new-label').textContent = set ? 'New PIN' : 'PIN';
pinSaveBtn.textContent = set ? 'Change PIN' : 'Set PIN';
pinStateEl.textContent = fromEnv
? 'A passcode is set in this server’s environment (PRODDASH_PASSCODE) and takes precedence. Anything set here applies once that is removed.'
: set
? 'Admin is locked. This page asks for the PIN, and so does every admin request.'
: 'Admin is open to anyone on the network. Set a 6-digit PIN to lock it.';
}
function pinMessage(text, bad = false) {
pinStatus.textContent = text;
pinStatus.className = 'admin-status' + (bad ? ' error' : ' ok');
}
async function savePin(clearing = false) {
pinStatus.textContent = '';
pinStatus.className = 'admin-status';
const current = pinCurrent.value;
const next = clearing ? '' : pinNew.value;
if (!clearing) {
if (!/^\d{6}$/.test(next)) return pinMessage('The PIN must be exactly 6 digits.', true);
if (next !== pinRepeat.value) return pinMessage('The two PINs are not the same.', true);
}
if (!pinCurrentField.hidden && !/^\d{6}$/.test(current)) {
return pinMessage('Enter the current PIN to change it.', true);
}
pinSaveBtn.disabled = true;
pinClearBtn.disabled = true;
try {
const body = await api('/api/admin/pin', { method: 'POST', body: JSON.stringify({ pin: next, current }) });
for (const i of [pinCurrent, pinNew, pinRepeat]) i.value = '';
pinMessage(body.pinSet ? 'PIN saved. It is asked for from the next visit.' : 'PIN removed — admin is open again.');
await loadState();
} catch (e) {
pinMessage(e.message, true);
} finally {
pinSaveBtn.disabled = false;
pinClearBtn.disabled = false;
}
}
pinSaveBtn.addEventListener('click', () => savePin(false));
pinClearBtn.addEventListener('click', () => {
if (confirm('Turn the admin PIN off? Anyone on the network could then change this server’s settings.')) savePin(true);
});
pinRepeat.addEventListener('keydown', (e) => { if (e.key === 'Enter') savePin(false); });
/* ── module cards ───────────────────────────────────────────────────── */
@@ -224,6 +316,73 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
field.append(span, select);
read = () => select.value;
selectEl = select;
} else if (type === 'multiselect' && (Array.isArray(spec.options) || spec.optionsRoute)) {
// Several picks from one list — a switch per option, stored as an array
// of values. A saved value the list no longer offers stays checked and
// marked, so a channel the account has left isn't dropped in silence.
field.classList.add('multiselect-field');
// The wrapper is a <label>; with several controls inside, a click on the
// wrapper would activate the FIRST one. `for=""` makes it label nothing.
field.htmlFor = '';
const span = document.createElement('span');
span.textContent = label;
const list = document.createElement('div');
list.className = 'multiselect';
const chosen = new Set((Array.isArray(value) ? value : []).map(String).filter(Boolean));
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
const fill = (options) => {
for (const input of list.querySelectorAll('input')) {
if (input.checked) chosen.add(input.value);
else chosen.delete(input.value);
}
list.innerHTML = '';
const missing = [...chosen].filter((v) => !options.some((opt) => optValue(opt) === v));
const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))];
for (const opt of all) {
const row = document.createElement('div');
row.className = 'multiselect-row';
const toggle = document.createElement('span');
toggle.className = 'switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.value = optValue(opt);
input.checked = chosen.has(input.value);
const track = document.createElement('span');
track.className = 'track';
toggle.append(input, track);
const text = document.createElement('span');
text.className = 'switch-label';
text.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
row.append(toggle, text);
row.addEventListener('click', (e) => {
if (e.target === input) return; // the box toggles itself
input.checked = !input.checked;
input.dispatchEvent(new Event('change', { bubbles: true }));
});
list.appendChild(row);
}
if (!all.length) {
const none = document.createElement('div');
none.className = 'multiselect-empty';
none.textContent = 'Nothing to choose from yet.';
list.appendChild(none);
}
};
fill(Array.isArray(spec.options) ? spec.options : []);
if (spec.optionsRoute && moduleId) {
refetch = (query) => {
const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : '';
return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`)
.then((res) => (res.ok ? res.json() : null))
.then((body) => {
if (Array.isArray(body?.options)) fill(body.options);
})
.catch(() => { /* keep the static/saved options */ });
};
if (!spec.optionsDependsOn) refetch();
}
field.append(span, list);
read = () => [...list.querySelectorAll('input:checked')].map((input) => input.value);
} else if (type === 'color') {
// A swatch picker; the value is always a #rrggbb string.
field.classList.add('color-field');
@@ -235,6 +394,17 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
input.value = hex(value, hex(spec?.default, '#2ee59a'));
field.append(span, input);
read = () => input.value;
} else if (type === 'text') {
// A multi-line string — one item per line by convention (a list of
// presets, say). Stored as-is, newlines included.
const span = document.createElement('span');
span.textContent = label;
const textarea = document.createElement('textarea');
textarea.rows = 5;
textarea.spellcheck = false;
textarea.value = value === undefined || value === null ? '' : String(value);
field.append(span, textarea);
read = () => textarea.value;
} else {
const span = document.createElement('span');
span.textContent = label;
@@ -277,6 +447,99 @@ function groupHasValues(fields, mod) {
});
}
/* ── group title row: the ⓘ and the group toggle ────────────────────── */
let groupInfoSeq = 0;
function closeGroupInfos(except) {
for (const open of document.querySelectorAll('.group-info.is-open')) {
if (open === except) continue;
open.classList.remove('is-open');
open.querySelector('.group-info-btn')?.setAttribute('aria-expanded', 'false');
}
}
/**
* configGroups[name].help as an ⓘ beside the group title: click toggles a
* small popover with the text (hover and keyboard focus show it too), so a
* group can carry a paragraph without pushing its fields down the page. A
* real button, labelled, so it works from the keyboard; inside a <summary>
* it must never fold the group.
*/
function groupInfo(groupName, text) {
const wrap = document.createElement('span');
wrap.className = 'group-info';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'group-info-btn';
btn.textContent = 'i';
btn.setAttribute('aria-label', `About ${groupName}`);
btn.setAttribute('aria-expanded', 'false');
const pop = document.createElement('span');
pop.className = 'group-info-pop';
pop.id = `group-info-${++groupInfoSeq}`;
pop.setAttribute('role', 'note');
pop.textContent = text;
btn.setAttribute('aria-describedby', pop.id);
wrap.append(btn, pop);
const setOpen = (open) => {
wrap.classList.toggle('is-open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
};
btn.addEventListener('click', (e) => {
e.preventDefault(); // a click in a <summary> would otherwise fold the group
e.stopPropagation();
const open = !wrap.classList.contains('is-open');
closeGroupInfos(wrap);
setOpen(open);
});
pop.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
});
wrap.addEventListener('keydown', (e) => {
// Enter / Space activate the button (a click); they must not reach a
// <summary> above as well. Escape closes and hands focus back.
if (e.key === ' ' || e.key === 'Enter') e.stopPropagation();
if (e.key === 'Escape' && wrap.classList.contains('is-open')) {
e.stopPropagation();
setOpen(false);
btn.focus();
}
});
return wrap;
}
// A click anywhere else, or Escape, closes an open popover.
document.addEventListener('pointerdown', (e) => {
if (!(e.target instanceof Element) || !e.target.closest('.group-info')) closeGroupInfos();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeGroupInfos();
});
/**
* configGroups[name].toggle: the named switch/boolean field is the group's
* on/off. It moves into the title row, right-aligned, and the body dims
* while it is off (fields stay editable). The field itself is untouched —
* it is still read on save and still drives showWhen dependents.
*/
function mountGroupToggle(group, field, control) {
const slot = document.createElement('span');
slot.className = 'field-group-toggle';
slot.appendChild(field);
// Inside a <summary>, flipping the switch must not fold the group.
slot.addEventListener('click', (e) => e.stopPropagation());
slot.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key === 'Enter') e.stopPropagation();
});
group.title.appendChild(slot);
group.el.classList.add('has-toggle');
const sync = () => group.el.classList.toggle('is-off', !control.checked);
control.addEventListener('change', sync);
sync();
}
function buildModuleCard(mod) {
const card = document.createElement('section');
card.className = 'admin-card';
@@ -351,8 +614,11 @@ function buildModuleCard(mod) {
desc.textContent = mod.description;
card.appendChild(desc);
// The module's own words about its upstream ("polling timers on
// 10.3.11.20:1025") — a readout, so it sits in a bordered monospace box
// and the state (ok / connecting / error) is its text colour.
const healthEl = document.createElement('div');
healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : '');
healthEl.className = 'module-status ' + healthClass(mod);
healthEl.textContent = healthText(mod);
card.appendChild(healthEl);
@@ -395,7 +661,9 @@ function buildModuleCard(mod) {
const collapsibles = []; // { el, meta, fields } groups that fold
// Fields render in schema order; a `group` starts a labeled subsection
// and consecutive same-group fields share it. The manifest's optional
// `configGroups[name]` adds { collapsed, columns, help } for that group.
// `configGroups[name]` adds { collapsed, columns, help, toggle } for
// that group: help is an ⓘ beside the title, toggle names the switch
// that is the group's on/off and lives in the title row.
const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {};
let groupName = null;
let container = form;
@@ -412,7 +680,11 @@ function buildModuleCard(mod) {
group.className = 'field-group';
const title = document.createElement(folds ? 'summary' : 'div');
title.className = 'field-group-title';
title.textContent = g;
const titleName = document.createElement('span');
titleName.className = 'field-group-name';
titleName.textContent = g;
title.appendChild(titleName);
if (meta.help) title.appendChild(groupInfo(g, String(meta.help)));
group.appendChild(title);
const body = document.createElement('div');
body.className = 'field-group-body';
@@ -421,16 +693,16 @@ function buildModuleCard(mod) {
body.classList.add('is-columns');
body.style.setProperty('--cols', String(cols));
}
if (meta.help) {
const help = document.createElement('div');
help.className = 'field-group-help';
help.textContent = meta.help;
body.appendChild(help);
}
group.appendChild(body);
form.appendChild(group);
container = body;
currentGroup = { el: group, meta, fields: [] };
currentGroup = {
el: group,
title,
meta,
fields: [],
toggleKey: typeof meta.toggle === 'string' ? meta.toggle : '',
};
if (folds) collapsibles.push(currentGroup);
} else {
container = form;
@@ -444,7 +716,13 @@ function buildModuleCard(mod) {
else if (refetch) reloaders.push(() => refetch());
if (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
if (currentGroup) currentGroup.fields.push({ key, spec });
container.appendChild(field);
if (currentGroup && control && key === currentGroup.toggleKey) {
// The group's on/off: title row, not the body (never both). A
// toggle naming a non-boolean field is ignored and renders normally.
mountGroupToggle(currentGroup, field, control);
} else {
container.appendChild(field);
}
}
// A field with showWhen:"K" is visible only while K's toggle is on.
for (const dep of dependents) {
@@ -473,26 +751,52 @@ function buildModuleCard(mod) {
const actions = document.createElement('div');
actions.className = 'module-actions';
const save = document.createElement('button');
save.className = 'btn primary';
save.textContent = 'Save & re-init';
const status = document.createElement('span');
status.className = 'admin-status';
actions.append(save, status);
form.appendChild(actions);
actions.append(status, save);
// A card is a status line until someone wants to change something: the
// form starts folded, and the row at the bottom holds one button that
// does both jobs — "Configure" opens the form and becomes "Apply", which
// saves without closing it. The name is the other way in and out.
card.appendChild(form);
card.appendChild(actions);
const openKey = 'proddash:admin:open:' + mod.id;
const setOpen = (open) => {
form.hidden = !open;
status.hidden = !open;
save.textContent = open ? 'Apply' : 'Configure';
save.className = open ? 'btn primary' : 'btn';
// Folded, the readout shares the bottom row with Configure instead of
// taking a line of its own; open, it goes back above the form.
if (open) card.insertBefore(healthEl, mountError);
else actions.insertBefore(healthEl, status);
try { sessionStorage.setItem(openKey, open ? '1' : '0'); } catch { /* fine */ }
};
let wasOpen = false;
try { wasOpen = sessionStorage.getItem(openKey) === '1'; } catch { /* fine */ }
setOpen(wasOpen);
name.classList.add('is-toggle');
name.title = 'Show or hide this module\'s settings';
name.tabIndex = 0;
name.setAttribute('role', 'button');
name.addEventListener('click', () => setOpen(form.hidden));
name.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(form.hidden); }
});
save.addEventListener('click', async () => {
if (form.hidden) return setOpen(true);
const config = {};
for (const [key, read] of readers) config[key] = read();
save.disabled = true;
status.textContent = 'Saving…';
status.textContent = 'Applying…';
status.className = 'admin-status';
try {
await api(`/api/admin/modules/${encodeURIComponent(mod.id)}/config`, {
method: 'PUT',
body: JSON.stringify({ config }),
});
status.textContent = 'Saved — module re-initialised. Open tiles reconnect on their own.';
status.textContent = 'Applied — module re-initialised. Open tiles reconnect on their own.';
status.className = 'admin-status ok';
refreshStatus();
// What the module can list may have changed (credentials just
@@ -531,7 +835,7 @@ async function refreshStatus() {
if (!els) continue;
els.dot.className = 'dot ' + healthClass(mod);
els.healthEl.textContent = healthText(mod);
els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : '');
els.healthEl.className = 'module-status ' + healthClass(mod);
if (!mod.incompatible) {
els.mountError.hidden = !mod.mountError;
els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
@@ -543,7 +847,7 @@ async function refreshStatus() {
}
}
/* ── named layouts ──────────────────────────────────────────────────── */
/* ── saved layouts ──────────────────────────────────────────────────── */
async function loadLayouts() {
let body;
@@ -559,7 +863,7 @@ async function loadLayouts() {
if (!layouts.length) {
const none = document.createElement('div');
none.className = 'layouts-empty';
none.textContent = 'No named layouts saved yet. Save one from the dashboard’s Layout menu.';
none.textContent = 'No layouts saved yet. Save one from the dashboard’s Layout menu.';
layoutsList.appendChild(none);
return;
}
@@ -605,7 +909,25 @@ async function loadLayouts() {
else toast(e.message, true);
}
});
row.append(name, meta, rename, del);
// The copy the last save replaced, one back. Restoring swaps the two,
// so a restore is itself one restore away from being undone.
const restore = document.createElement('button');
restore.className = 'btn';
restore.textContent = 'Restore previous';
restore.title = 'Put back the copy the last save replaced';
restore.hidden = !layout.hasPrevious;
restore.addEventListener('click', async () => {
if (!confirm(`Go back to the previous version of “${layout.name}”? The current one becomes its previous version, so this can be undone the same way.`)) return;
try {
await api(`/api/layouts/${encodeURIComponent(layout.name)}/restore`, { method: 'POST', body: '{}' });
toast('Previous version restored');
loadLayouts();
} catch (e) {
if (e.authRequired) showLogin();
else toast(e.message, true);
}
});
row.append(name, meta, restore, rename, del);
layoutsList.appendChild(row);
}
}
@@ -640,10 +962,134 @@ async function loadState() {
for (const mod of state.modules || []) {
modulesList.appendChild(buildModuleCard(mod));
}
renderPin(state);
loadTheme();
loadLayouts();
loadCatalog(false);
}
/* ── theme ──────────────────────────────────────────────────────────── */
/* The dashboard palette is a server-wide choice. The server lists the
presets (GET /api/theme — public, dashboards need it too) and the admin
page shows them as swatches; picking one POSTs it, applies it here at
once, and the server's `theme` shell event switches every open dashboard
— and any other admin page — live. The palettes themselves are
html[data-theme="…"] variable sets in the shell's style.css, which this
page shares, so the admin page follows its own choice. */
const LS_THEME = 'proddash:theme';
let themeList = [];
let currentTheme = '';
function applyTheme(id) {
const theme = String(id || '');
if (!/^[a-z][a-z0-9-]{0,31}$/.test(theme)) return;
document.documentElement.dataset.theme = theme;
try { localStorage.setItem(LS_THEME, theme); } catch { /* private mode — fine */ }
}
// The last-known theme first (shared with the dashboard, same origin), so
// the page doesn't flash the default palette before the server answers.
try { applyTheme(localStorage.getItem(LS_THEME)); } catch { /* fine */ }
function themeName(id) {
return themeList.find((t) => t.id === id)?.name || id;
}
function renderThemes() {
themeSwatches.innerHTML = '';
for (const t of themeList) {
const current = t.id === currentTheme;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'theme-swatch' + (current ? ' is-current' : '');
btn.setAttribute('role', 'radio');
btn.setAttribute('aria-checked', current ? 'true' : 'false');
btn.title = current ? `${t.name} — the current theme` : `Switch every dashboard to ${t.name}`;
const preview = document.createElement('span');
preview.className = 'theme-preview';
preview.style.setProperty('--sw-bg', String(t.colors?.bg || '#000'));
preview.style.setProperty('--sw-panel', String(t.colors?.panel || '#222'));
preview.style.setProperty('--sw-accent', String(t.colors?.accent || '#fff'));
const name = document.createElement('span');
name.className = 'theme-name';
name.textContent = t.name;
btn.append(preview, name);
btn.addEventListener('click', () => chooseTheme(t.id));
themeSwatches.appendChild(btn);
}
}
/**
* Fetch the server's theme and list. Applied to this page either way (the
* endpoint is public, so even the login card wears the right palette); the
* swatch section itself is only revealed for an unlocked page.
*/
async function loadTheme(reveal = true) {
let body;
try {
body = await api('/api/theme');
} catch {
if (reveal) themeSection.hidden = true; // a server without themes — nothing to offer
return;
}
themeList = Array.isArray(body.themes) ? body.themes.filter((t) => t && t.id && t.name) : [];
currentTheme = String(body.theme || '');
applyTheme(currentTheme);
renderThemes();
if (reveal) themeSection.hidden = !themeList.length;
}
loadTheme(false);
async function chooseTheme(id) {
if (id === currentTheme) return;
const previous = currentTheme;
currentTheme = id;
applyTheme(id); // at once, here — the server's event confirms it everywhere else
renderThemes();
themeStatus.textContent = '';
themeStatus.className = 'admin-status';
try {
await api('/api/admin/theme', { method: 'POST', body: JSON.stringify({ theme: id }) });
themeStatus.textContent = `${themeName(id)} — every open dashboard switched.`;
themeStatus.className = 'admin-status ok';
} catch (e) {
currentTheme = previous;
applyTheme(previous);
renderThemes();
themeStatus.textContent = e.message;
themeStatus.className = 'admin-status error';
if (e.authRequired) showLogin();
}
}
// Follow a theme change made elsewhere (another admin page) live.
let themeEvents = null;
let themeEventsRetry = null;
function subscribeThemeEvents() {
try { themeEvents?.close(); } catch { /* not open */ }
themeEvents = new EventSource('/api/events');
themeEvents.addEventListener('theme', (e) => {
let theme = '';
try { theme = String(JSON.parse(e.data).theme || ''); } catch { return; }
if (!theme || theme === currentTheme) return;
currentTheme = theme;
applyTheme(theme);
renderThemes();
});
themeEvents.onerror = () => {
if (themeEvents.readyState === EventSource.CLOSED) {
clearTimeout(themeEventsRetry);
themeEventsRetry = setTimeout(subscribeThemeEvents, 4000);
}
};
}
subscribeThemeEvents();
/* ── the repo: available modules, module updates, shell update ──────── */
function fmtTime(ms) {
+41 -11
View File
@@ -18,10 +18,11 @@
<main id="admin-main">
<section class="admin-card" id="login-card" hidden>
<h2>Admin passcode</h2>
<h2>Admin PIN</h2>
<p class="admin-copy">This page changes the server-wide configuration for every dashboard in the building.</p>
<div class="login-row">
<input type="password" id="passcode" placeholder="Passcode" autocomplete="current-password">
<input type="password" id="passcode" class="pin-input" inputmode="numeric" autocomplete="off"
maxlength="6" placeholder="••••••" aria-label="Admin PIN">
<button class="btn primary" id="login-btn">Unlock</button>
</div>
<div class="admin-status" id="login-status"></div>
@@ -39,11 +40,16 @@
<div class="admin-status" id="update-status"></div>
</section>
<section id="theme-section" hidden>
<details class="admin-fold">
<summary class="admin-h1 admin-fold-title">Theme</summary>
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
<div class="admin-status" id="theme-status"></div>
</details>
</section>
<section id="modules-section" hidden>
<h1 class="admin-h1">Modules</h1>
<p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the
module immediately — open tiles reconnect on their own. Per-tile options (filters, text size)
live in each tile's gear menu instead.</p>
<p class="admin-copy admin-storage" id="storage-note" hidden>Saved on the server at
<code id="storage-path"></code> — outside the app folder, so updating ProdDash keeps them.</p>
<div id="modules-list"></div>
@@ -51,9 +57,6 @@
<section id="available-section" hidden>
<h1 class="admin-h1">Available modules</h1>
<p class="admin-copy">Modules in the ProdDash repo that aren't installed here. Installing downloads the module
into the server's data directory — no restart, nothing written into the app folder. A module that needs a
newer ProdDash can't be installed until the shell is updated.</p>
<div class="catalog-toolbar">
<button class="btn small" id="catalog-refresh">Check again</button>
<span class="admin-status" id="catalog-status"></span>
@@ -62,11 +65,38 @@
</section>
<section id="layouts-section" hidden>
<h1 class="admin-h1">Named layouts</h1>
<p class="admin-copy">Layouts saved to the server from the dashboard's Layout menu. Loading one
copies it into that browser; deleting one here does not touch any browser's current layout.</p>
<h1 class="admin-h1">Saved layouts</h1>
<div id="layouts-list"></div>
</section>
<section id="security-section" hidden>
<h1 class="admin-h1">Admin PIN</h1>
<p class="admin-copy" id="pin-state"></p>
<div class="admin-card">
<div class="pin-form">
<label class="pin-field" id="pin-current-field" hidden>
<span>Current PIN</span>
<input type="password" id="pin-current" class="pin-input" inputmode="numeric"
autocomplete="off" maxlength="6" placeholder="••••••">
</label>
<label class="pin-field">
<span id="pin-new-label">New PIN</span>
<input type="password" id="pin-new" class="pin-input" inputmode="numeric"
autocomplete="off" maxlength="6" placeholder="6 digits">
</label>
<label class="pin-field">
<span>Repeat</span>
<input type="password" id="pin-repeat" class="pin-input" inputmode="numeric"
autocomplete="off" maxlength="6" placeholder="6 digits">
</label>
<div class="pin-actions">
<button class="btn primary" id="pin-save">Set PIN</button>
<button class="btn" id="pin-clear" hidden>Turn off</button>
</div>
</div>
<div class="admin-status" id="pin-status"></div>
</div>
</section>
</main>
<div id="toast" hidden></div>
+17 -11
View File
@@ -15,12 +15,19 @@
</head>
<body>
<header id="topbar">
<!-- invisible twin of the logo: holds its place in the bar; the real logo is the handle below -->
<span id="brand" class="brand-spacer" aria-hidden="true">Prod<span class="accent">Dash</span></span>
<span id="brand">Prod<span class="accent">Dash</span></span>
<span class="spacer"></span>
<button class="tb-btn icon" id="fs-btn" title="Fullscreen — auto-fits the layout to the screen" aria-label="Fullscreen">
<button class="tb-btn icon" id="autohide-btn" title="Auto-hide bars" aria-label="Auto-hide tile title bars" aria-pressed="false">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 6l3-3 3 3"/>
<rect x="4" y="10" width="16" height="10" rx="1.5"/>
<path d="M4 14h16"/>
</svg>
</button>
<button class="tb-btn icon" id="fs-btn" title="Fullscreen" aria-label="Fullscreen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 9V5a1 1 0 0 1 1-1h4"/><path d="M20 9V5a1 1 0 0 0-1-1h-4"/>
<path d="M4 15v4a1 1 0 0 0 1 1h4"/><path d="M20 15v4a1 1 0 0 1-1 1h-4"/>
@@ -28,12 +35,14 @@
</button>
<div class="dropdown" id="add-dd">
<button class="tb-btn icon" id="add-btn" title="Add a module tile" aria-label="Add tile">+</button>
<button class="tb-btn icon" id="add-btn" title="Add tile" aria-label="Add tile">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
</button>
<div class="menu" id="add-menu" hidden></div>
</div>
<div class="dropdown" id="layout-dd">
<button class="tb-btn icon" id="layout-btn" title="Save, load or reset layouts" aria-label="Layouts">
<button class="tb-btn icon" id="layout-btn" title="Layouts" aria-label="Layouts">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="3" y="3" width="8" height="8" rx="1.5"/>
<rect x="3" y="13" width="8" height="8" rx="1.5"/>
@@ -43,15 +52,12 @@
<div class="menu" id="layout-menu" hidden></div>
</div>
<a class="tb-btn" id="admin-link" href="/admin/" title="Configure modules (server-wide)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm8.4-2.1.1-1.4-.1-1.4 1.7-1.3-1.6-2.8-2 .7a7.9 7.9 0 0 0-2.4-1.4l-.3-2.1H11l-.3 2.1a7.9 7.9 0 0 0-2.4 1.4l-2-.7L4.7 9.3 6.4 10.6l-.1 1.4.1 1.4-1.7 1.3 1.6 2.8 2-.7a7.9 7.9 0 0 0 2.4 1.4l.3 2.1h2.8l.3-2.1a7.9 7.9 0 0 0 2.4-1.4l2 .7 1.6-2.8-1.7-1.3Z"/>
</svg>
Admin
<a class="tb-btn icon" id="admin-link" href="/admin/" title="Admin" aria-label="Admin">
<svg width="14" height="14" viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>
</a>
</header>
<button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar">Prod<span class="accent">Dash</span></button>
<button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar"></button>
<main id="gridwrap">
<div id="grid">
+1234 -216
View File
File diff suppressed because it is too large Load Diff
+526 -171
View File
@@ -10,6 +10,7 @@
--text: #e6edf3;
--muted: #8b98a5;
--accent: #2ee59a;
--accent-text: #06281a; /* text that sits on --accent (buttons, badges) */
--danger: #f4433c;
--warn: #f0b429;
/* grid geometry (shell.js reads these) */
@@ -54,57 +55,43 @@ body.menu-hidden #topbar {
overflow: hidden;
}
/* The menubar's show/hide handle IS the logo: a translucent accent pill
wrapped around "ProdDash" at the left of the bar, "Dash" cut out in the
background colour. It sits at the left — never screen-centre, where a
tile's own (centred) tab lands once both bars are hidden. When the bar
hides, the pill shrinks to a slim lip at the very top edge, clear of a
top-row tile's status dot and title. */
.brand-spacer { visibility: hidden; }
/* The bar's show/hide handle: a small accent lip on its bottom edge, the
same idea as a tile's notch. It sits at the left — never screen-centre,
where a tile's own (centred) tab lands once both bars are hidden — and
grows under the pointer so it is easy to hit without being loud at rest.
It used to be the ProdDash wordmark on a pill hanging past the bar, which
read as a step cut out of the bar, and animating its font-size on the way
out was the jerkiest thing on the page in Safari. Everything here moves by
transform, opacity and its own fixed size: no text metrics, no reflow. */
#topbar-notch {
position: fixed;
/* a tab hung from the top of the bar: square top, rounded bottom, hanging
8px past the bar's bottom edge; the logo sits centred in the bar part */
top: 0;
left: 2px; /* the logo text starts at the bar's 12px padding; the tab adds 10px each side */
height: calc(var(--topbar-h, 46px) + 8px);
min-width: 60px;
padding: 0 10px;
font: inherit;
font-weight: 800;
font-size: 15px;
letter-spacing: 0.02em;
line-height: var(--topbar-h, 46px);
color: var(--text);
background: color-mix(in srgb, var(--accent) 45%, transparent);
left: 10px;
width: 28px;
height: 5px;
padding: 0;
border: none;
border-radius: 0 0 12px 12px;
border-radius: 0 0 5px 5px;
background: var(--accent);
opacity: 0.5;
cursor: pointer;
z-index: 60;
transition: height 0.22s ease, line-height 0.22s ease, font-size 0.22s ease, background 0.15s ease;
/* Glued to the bar's bottom edge: --topbar-h is the bar's live height, so
the lip rides the bar's own collapse instead of running a second,
competing animation against it. */
transform: translateY(var(--topbar-h, 46px));
transition: width 0.16s ease, height 0.16s ease,
opacity 0.16s ease, border-radius 0.16s ease;
}
#topbar-notch .accent { color: var(--bg); } /* the cutout */
#topbar-notch:hover { background: color-mix(in srgb, var(--accent) 68%, transparent); }
/* bar hidden: the text folds away and the tab becomes a lip at the top edge */
body.menu-hidden #topbar-notch {
#topbar-notch:hover,
#topbar-notch:focus-visible {
width: 60px;
height: 10px;
line-height: 10px;
font-size: 0;
background: color-mix(in srgb, var(--accent) 55%, transparent);
}
body.menu-hidden #topbar-notch:hover { background: var(--accent); }
body.menu-hidden #topbar-notch::after {
content: "";
display: block;
width: 20px;
height: 3px;
margin: 0 auto;
position: relative;
top: 3px;
border-radius: 999px;
background: var(--bg);
opacity: 1;
border-radius: 0 0 8px 8px;
}
/* With the bar collapsed --topbar-h is 0, so the lip ends up at the very top
edge without a rule of its own. */
#brand {
font-weight: 800;
@@ -139,6 +126,14 @@ body.menu-hidden #topbar-notch::after {
line-height: 1;
}
.tb-btn.icon svg { display: block; }
/* A top-bar button that is a switch rather than an action lights up the same
way an open tile button does, so "on" reads the same everywhere. */
.tb-btn.is-on,
.tb-btn.is-on:hover {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text);
}
/* ── dropdown menus ────────────────────────────────────────────────── */
@@ -183,6 +178,7 @@ body.menu-hidden #topbar-notch::after {
margin-top: 2px;
white-space: normal;
}
.menu-item[disabled] { opacity: 0.45; cursor: default; }
.menu-item.danger { color: var(--danger); }
.menu-note {
padding: 8px 10px;
@@ -246,6 +242,18 @@ body.menu-hidden #topbar-notch::after {
overflow: hidden;
}
/* A tile the user gave a colour: a wash over the panel, a stronger edge and
a tinted title bar. Module content keeps its own colours — this is the
tile's chrome, so a tinted tile still reads like every other one. */
.tile.is-tinted {
background: color-mix(in srgb, var(--tile-tint) 14%, var(--panel));
border-color: color-mix(in srgb, var(--tile-tint) 45%, var(--border));
}
.tile.is-tinted > .tile-head {
background: color-mix(in srgb, var(--tile-tint) 20%, transparent);
border-bottom-color: color-mix(in srgb, var(--tile-tint) 35%, var(--border));
}
.tile.dragging,
.tile.resizing {
z-index: 40;
@@ -270,11 +278,33 @@ body.menu-hidden #topbar-notch::after {
transition: height 0.22s ease, border-bottom-width 0.22s ease;
}
.tile.dragging .tile-head { cursor: grabbing; }
/* hidden = collapsed, not removed, so the notch toggle animates */
.tile.head-hidden .tile-head {
height: 0;
border-bottom-width: 0;
overflow: hidden;
/* Folded away: the bar leaves the flow and parks just above the tile, so the
module keeps the whole body and nothing reflows as the bar comes and goes.
Pointing at the tile's top edge slides it back in over the content
(shell.js adds .head-peek) — and because it is the same element, its
buttons click and its width drags the tile just as they always did. */
.tile.head-off > .tile-head {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 7;
background: color-mix(in srgb, var(--panel) 90%, transparent);
-webkit-backdrop-filter: blur(7px);
backdrop-filter: blur(7px);
transform: translateY(-100%);
opacity: 0;
pointer-events: none;
transition: transform 0.18s ease, opacity 0.18s ease;
}
.tile.head-off.head-peek > .tile-head {
transform: none;
opacity: 1;
pointer-events: auto;
}
.tile.is-tinted.head-off > .tile-head {
background: color-mix(in srgb, var(--tile-tint) 20%, var(--panel));
}
.tile-head .dot {
@@ -290,7 +320,7 @@ body.menu-hidden #topbar-notch::after {
.tile-head .dot.error { background: var(--danger); }
.tile-title {
flex: 1;
flex: 0 1 auto;
min-width: 0;
font-size: 12px;
font-weight: 700;
@@ -318,6 +348,53 @@ body.menu-hidden #topbar-notch::after {
.tile-btn:hover { background: var(--border); color: var(--text); }
.tile-btn.close:hover { color: var(--danger); }
.tile-btn.active { color: var(--accent); }
.tile-btn.is-open,
.tile-btn.is-open:hover { background: var(--accent); color: var(--accent-text); }
/* The pin: a state, not an action, so it stays quiet until it is on and
sits closer to the label than to the buttons at the other end. It fades
in with the rest of the tile's chrome so a wall of unpinned tiles is not
a wall of pins. */
.tile-btn.pin {
width: 18px;
height: 18px;
margin-left: 2px;
opacity: 0;
transition: opacity 0.15s ease;
}
.tile-btn.pin svg { width: 12px; height: 12px; }
.tile:hover .tile-btn.pin,
.tile-btn.pin:focus-visible { opacity: 0.75; }
.tile-btn.pin:hover { opacity: 1; }
/* Pinned reads at a glance, whether or not anything is being pointed at. */
.tile-btn.pin.is-pinned,
.tile:hover .tile-btn.pin.is-pinned {
opacity: 1;
color: var(--accent);
}
.tile-btn.pin.is-pinned .pin-head { fill: currentColor; }
.tile-btn.pin.is-pinned:hover { background: var(--border); }
/* Keeps the label hugging its pin however wide the tile is. */
.tile-head-gap { flex: 1 1 auto; min-width: 0; }
/* the shell's tooltip: shown at once under the button the pointer is on */
#tip {
position: fixed;
z-index: 200;
pointer-events: none;
padding: 3px 8px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--panel);
color: var(--text);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
}
#tip[hidden] { display: none; }
.tile-btn svg { width: 13px; height: 13px; display: block; }
/* controls a module adds to its own title bar (moduleApi.header) */
@@ -354,39 +431,6 @@ body.menu-hidden #topbar-notch::after {
}
.tile-menu[hidden] { display: none; }
/* notch on the title bar's bottom edge: click to hide/show the bar
(same idea as the ProdCom Listener's pull-down menubar handle) */
.tile-notch {
position: absolute;
top: 32px;
left: 50%;
transform: translateX(-50%);
width: 46px;
height: 13px;
padding: 0;
background: var(--panel);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 10px 10px;
cursor: pointer;
z-index: 6;
opacity: 0;
transition: opacity 0.15s ease, top 0.22s ease;
}
.tile:hover .tile-notch { opacity: 0.6; }
.tile .tile-notch:hover { opacity: 1; }
.tile.head-hidden .tile-notch { top: 0; }
.tile-notch::after {
content: "";
display: block;
width: 18px;
height: 3px;
margin: 4px auto 0;
border-radius: 999px;
background: var(--muted);
}
.tile-notch:hover::after { background: var(--text); }
.tile-body {
flex: 1;
position: relative;
@@ -395,82 +439,67 @@ body.menu-hidden #topbar-notch::after {
overflow: hidden;
}
/* ── resizing: the whole rim of a tile, drawn nowhere ────────────────
Every edge and both bottom corners resize, so there is nothing to point
at — the grips are invisible and the cursor says what each one does, the
way a window's own edges do. Together they tile the tile's outer 8px
without a gap: the top strip runs corner to corner, the sides run between
it and the bottom corners, and the bottom strip fills between those. */
.tile-resize,
.tile-resize-left,
.tile-resize-bottom,
.tile-resize-top,
.tile-resize-right,
.tile-resize-side {
position: absolute;
z-index: 5;
touch-action: none;
}
/* the two bottom corners: both directions at once */
.tile-resize,
.tile-resize-left {
position: absolute;
bottom: 0;
width: 20px;
height: 20px;
touch-action: none;
z-index: 5;
color: var(--muted);
opacity: 0; /* corner indicators only show while hovering the tile */
transition: opacity 0.15s ease;
}
.tile-resize { right: 0; cursor: nwse-resize; }
.tile-resize-left { left: 0; cursor: nesw-resize; }
.tile:hover .tile-resize,
.tile:hover .tile-resize-left { opacity: 0.6; }
.tile .tile-resize:hover,
.tile .tile-resize-left:hover { opacity: 1; color: var(--text); }
.tile-resize::after,
.tile-resize-left::after {
content: "";
position: absolute;
bottom: 4px;
width: 9px;
height: 9px;
border-bottom: 2px solid currentColor;
}
.tile-resize::after {
right: 4px;
border-right: 2px solid currentColor;
border-radius: 0 0 3px 0;
}
.tile-resize-left::after {
left: 4px;
border-left: 2px solid currentColor;
border-radius: 0 0 0 3px;
}
/* bottom-edge pill: drag to move the tile's bottom edge (height only);
sits between the two corner handles */
/* top and bottom: height only */
.tile-resize-top,
.tile-resize-bottom {
position: absolute;
left: 24px;
right: 24px;
bottom: 0;
height: 10px;
z-index: 5;
display: flex;
align-items: flex-end;
justify-content: center;
height: 8px;
cursor: ns-resize;
touch-action: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.tile-resize-bottom::after {
content: "";
width: 44px;
max-width: 60%;
height: 5px;
margin-bottom: 2px;
border-radius: 999px;
background: var(--muted);
transition: background 0.15s ease;
.tile-resize-top {
top: 0;
left: 0;
right: 0;
/* Above a revealed title bar: the bar slides in under the pointer here,
and this strip has to stay the top edge rather than become part of it. */
z-index: 8;
}
.tile:hover .tile-resize-bottom { opacity: 0.6; }
.tile .tile-resize-bottom:hover,
.tile.resizing .tile-resize-bottom { opacity: 1; }
.tile .tile-resize-bottom:hover::after,
.tile.resizing .tile-resize-bottom::after { background: var(--text); }
.tile-resize-bottom { bottom: 0; left: 20px; right: 20px; }
/* left and right: width only, between the top strip and the bottom corners */
.tile-resize-right,
.tile-resize-side {
top: 8px;
bottom: 20px;
width: 8px;
cursor: ew-resize;
}
.tile-resize-right { right: 0; }
.tile-resize-side { left: 0; }
/* ── seam divider between adjacent tiles ───────────────────────────── */
/* When two tiles share a vertical edge, an invisible strip sits on the seam;
hovering reveals a small macOS-split-view-style grab pill. Dragging it
trades width between the two tiles. */
/* When two tiles share a vertical edge, an invisible strip sits on the seam.
Dragging it trades width between the two tiles rather than resizing one,
so it sits above their own edge grips. Like them it draws nothing at rest
— only while it is being dragged, to show which seam is moving. */
.tile-divider {
position: absolute;
width: 14px;
@@ -494,21 +523,24 @@ body.menu-hidden #topbar-notch::after {
transition: opacity 0.15s ease, background 0.15s ease;
}
.tile-divider:hover::after { opacity: 0.7; }
.tile-divider.active::after { opacity: 1; background: var(--text); }
/* Hide the pills while a tile itself is being moved or corner-resized —
they're rebuilt for the new geometry on release. */
/* Drop the seams while a tile itself is being moved or resized — they're
rebuilt for the new geometry on release. */
#grid.no-dividers .tile-divider { display: none; }
/* ── fullscreen mode ───────────────────────────────────────────────── */
/* Fullscreen is a viewing mode: the layout auto-fits the screen, so the
editing affordances (corner handles, seam pills) are parked. */
editing affordances (the rim grips and the seams) are parked. */
body.fullscreen .tile-resize,
body.fullscreen .tile-resize-left,
body.fullscreen .tile-resize-bottom,
body.fullscreen .tile-resize-top,
body.fullscreen .tile-resize-right,
body.fullscreen .tile-resize-side,
body.fullscreen .tile-divider,
body.fullscreen #autohide-btn,
body.fullscreen #admin-link { display: none; }
body.fullscreen #grid { padding-bottom: 0; }
#gridwrap.no-scroll { overflow-y: hidden; }
@@ -531,28 +563,122 @@ body.fullscreen #grid { padding-bottom: 0; }
/* ── per-tile settings popover ─────────────────────────────────────── */
/* A small window beside its tile (shell.js places and clamps it), not a
box inside the tile: a tile can be smaller than its own settings, and the
whole point of live settings is watching the tile change. */
.tile-settings {
position: absolute;
top: 36px;
right: 6px;
z-index: 45;
min-width: 220px;
max-width: calc(100% - 12px);
max-height: calc(100% - 40px);
overflow-y: auto;
position: fixed;
z-index: 80;
display: flex;
flex-direction: column;
/* as wide as its content wants and no wider: a couple of switches make a
small window, a three-column checklist a wide one */
width: max-content;
min-width: 150px;
max-width: min(560px, calc(100vw - 16px));
max-height: calc(100vh - 16px);
overflow: hidden;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55);
}
/* the grab bar: a slim accent strip with a grip mark, nothing to read or
press — the gear that opened the window wears the same colour meanwhile */
.tile-settings-bar {
flex: none;
height: 14px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent);
cursor: grab;
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
.tile-settings-bar::before {
content: "";
width: 34px;
height: 4px;
border-radius: 2px;
background: var(--accent-text);
opacity: 0.7;
transition: opacity 0.12s;
}
.tile-settings-bar:hover::before { opacity: 1; }
.tile-settings .field[hidden] { display: none; }
/* the tint, the shell's own setting, under a hairline at the foot */
.tile-settings .tint-field {
margin: 8px 0 0;
padding-top: 9px;
border-top: 1px solid var(--border);
gap: 8px;
}
.tile-settings .tint-clear {
flex: none;
padding: 1px 7px;
border: 1px solid var(--border);
border-radius: 999px;
background: none;
color: var(--muted);
font: inherit;
font-size: 11px;
cursor: pointer;
}
.tile-settings .tint-clear:hover { color: var(--text); background: var(--border); }
.tile-settings .tint-clear[hidden] { display: none; }
.tile-settings-bar:active { cursor: grabbing; }
.tile-settings-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
}
/* Help lines never widen the window: they take no room of their own when the
content is measured, then wrap to whatever width the fields settled on. */
.tile-settings .field-help,
.tile-settings .field-group-help,
.tile-settings .multiselect-empty { width: 0; min-width: 100%; }
/* a group's on/off sits in its title row; the body dims while it is off */
.tile-settings .field-group-title { display: flex; align-items: center; gap: 8px; }
.tile-settings .field-group-name { flex: 1; min-width: 0; }
.tile-settings .group-toggle { margin: 0 0 0 auto; }
.tile-settings .group-toggle .check-label { display: none; }
.tile-settings .field-group.is-off .field-group-body { opacity: 0.45; }
/* several picks from a list (type `multiselect`) */
.tile-settings .multiselect-field { display: flex; flex-direction: column; gap: 4px; }
.tile-settings .multiselect { display: flex; flex-direction: column; gap: 3px; }
.tile-settings .multiselect.is-columns {
display: grid;
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
gap: 3px 14px;
}
.tile-settings .multiselect-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text);
cursor: pointer;
}
.tile-settings .multiselect-row .check-label { flex: 1 1 0; min-width: 0; }
.tile-settings .multiselect-empty { color: var(--muted); font-size: 12px; }
/* a text built from offered variables (type `template`) */
.tile-settings .template-vars { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 2px; }
.tile-settings .template-var {
font: inherit;
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
cursor: grab;
}
.tile-settings .template-var:hover { border-color: var(--accent); }
.tile-settings .template-var:active { cursor: grabbing; }
/* A popover with grouped settings needs room for its columns; it still
never grows past the tile (max-width above). */
.tile-settings.has-groups {
min-width: min(540px, calc(100% - 12px));
container-type: inline-size;
}
.tile-settings .field-group {
margin: 0 0 10px;
padding: 8px 10px;
@@ -588,19 +714,20 @@ body.fullscreen #grid { padding-bottom: 0; }
.tile-settings details.field-group[open] > summary.field-group-title::before { transform: rotate(90deg); }
.tile-settings details.field-group[open] > summary.field-group-title { margin-bottom: 8px; }
.tile-settings .field-group .field:last-child { margin-bottom: 0; }
/* Settings apply as they change, so nothing sits under the last field — its
bottom margin would only pad the popover's own padding a second time. */
.tile-settings-body > :last-child { margin-bottom: 0; }
.tile-settings .field-group-body.is-columns {
display: grid;
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
gap: 3px 14px;
gap: 6px 14px; /* the grid gap is the whole row spacing: no per-field margin */
}
.tile-settings .field-group-body.is-columns .field { margin-bottom: 3px; }
.tile-settings .field-group-body.is-columns .field { margin-bottom: 0; }
.tile-settings .field-group-body.is-columns .field-group-help { grid-column: 1 / -1; }
@container (max-width: 420px) {
.tile-settings .field-group-body.is-columns { grid-template-columns: 1fr 1fr; }
}
@container (max-width: 260px) {
.tile-settings .field-group-body.is-columns { grid-template-columns: 1fr; }
}
/* a list of switches or a template with its chips is a row of its own, however
many columns the group lays its other fields out in */
.tile-settings .field-group-body.is-columns .multiselect-field,
.tile-settings .field-group-body.is-columns .template-field { grid-column: 1 / -1; }
.tile-settings .field-group-help,
.tile-settings .field-help {
display: block;
@@ -635,14 +762,64 @@ body.fullscreen #grid { padding-bottom: 0; }
outline: none;
border-color: var(--accent);
}
/* boolean / switch settings: label on the left, switch on the right (help,
when there is any, wraps under both) */
.field.check {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 8px;
justify-content: space-between;
gap: 4px 10px;
color: var(--text);
cursor: pointer;
}
.field.check input { width: 15px; height: 15px; accent-color: var(--accent); }
.field.check .check-label { flex: 1 1 0; min-width: 0; }
.field.check .field-help { flex-basis: 100%; }
/* The switch: the admin page's module switch, same track, knob and colours,
so a toggle reads the same on both surfaces. On = shown/enabled (accent),
off = hidden/disabled (border track, muted knob). The real checkbox lies
invisibly over the track — still focusable, so Tab reaches it and Space
flips it — and the label around it makes the text clickable too. */
.switch {
position: relative;
display: inline-flex;
align-items: center;
flex: none;
cursor: pointer;
}
.switch input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
pointer-events: none;
}
.switch .track {
display: block;
position: relative;
width: 34px;
height: 19px;
border-radius: 999px;
background: var(--border);
transition: background 0.2s;
}
.switch .track::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 15px;
height: 15px;
border-radius: 50%;
background: var(--muted);
transition: transform 0.2s, background 0.2s;
}
.switch input:checked + .track { background: var(--accent); }
.switch input:checked + .track::after { transform: translateX(15px); background: var(--bg); }
.switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
/* color fields: label on the left, swatch on the right */
.field.color-field {
@@ -708,7 +885,7 @@ body.fullscreen #grid { padding-bottom: 0; }
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #06281a;
color: var(--accent-text);
font-weight: 600;
}
@@ -729,6 +906,24 @@ body.fullscreen #grid { padding-bottom: 0; }
transition: opacity 0.25s ease;
}
#toast[hidden] { display: none; }
#toast button {
margin-left: 12px;
padding: 3px 10px;
border: 1px solid var(--accent);
border-radius: 5px;
background: none;
color: var(--accent);
font: inherit;
font-weight: 700;
cursor: pointer;
}
#toast button:hover { background: var(--accent); color: var(--accent-text); }
#modal .modal-copy {
color: var(--muted);
font-size: 13px;
line-height: 1.5;
margin: 6px 0 14px;
}
#toast.error { border-color: var(--danger); }
/* ── scrollbars ────────────────────────────────────────────────────── */
@@ -755,3 +950,163 @@ body.fullscreen #grid { padding-bottom: 0; }
@media (max-width: 560px) {
#topbar { padding: 0 8px; gap: 6px; }
}
/* ── themes ────────────────────────────────────────────────────────── */
/* Preset palettes, chosen in Admin → Theme and applied by shell.js (and the
admin page) as html[data-theme="…"]. Each swaps exactly the eight palette
variables the module guide names — nothing else — so every module and
every shell element follows, and a colour a tile is configured with (set
on the tile itself) still wins. The :root block at the top of this file is
"booth", the default; the list of ids and names lives in server.js
(GET /api/theme). Every one is booth-grade: high contrast, the accent
nowhere near the danger red or the warning amber, muted still readable.
Each sets --accent-text too when the default (a near-black green, for
booth's mint) would look wrong against its own accent. */
/* Harbor — deep navy, cyan accent */
html[data-theme="harbor"] {
--bg: #06101f;
--panel: #0c1a30;
--border: #1a2b48;
--text: #e6eefb;
--muted: #8ea4c8;
--accent: #2ad4ee;
--danger: #ff5a5f;
--warn: #f7b53b;
}
/* Graphite — neutral charcoal, electric-blue accent */
html[data-theme="graphite"] {
--bg: #0f1012;
--panel: #17191d;
--border: #2a2d33;
--text: #f0f2f4;
--muted: #9ca3ad;
--accent: #6cb4ff;
--danger: #ff4d4f;
--warn: #ffb224;
}
/* Ember — warm broadcast black, orange accent (danger stays a cool red,
warn a pale yellow, so the three status colours never blur) */
html[data-theme="ember"] {
--bg: #120c0a;
--panel: #1d1411;
--border: #35251e;
--text: #f6ede6;
--muted: #b39b8c;
--accent: #ff8f3a;
--danger: #f03247;
--warn: #ffd86b;
}
/* Daylight — the light one, for a front-of-house desk in daytime */
html[data-theme="daylight"] {
--bg: #eef1f5;
--panel: #ffffff;
--border: #cfd6e0;
--text: #14181f;
--muted: #5b6675;
--accent: #0b8a5f;
--accent-text: #ffffff; /* dark text drowns on this accent */
--danger: #d1242f;
--warn: #b26a00;
}
/* Midnight — indigo night, violet accent */
html[data-theme="midnight"] {
--bg: #0a0b18;
--panel: #14162b;
--border: #272a4a;
--text: #e8e9fb;
--muted: #9a9cc8;
--accent: #9d8cff;
--accent-text: #12103a;
--danger: #ff5a72;
--warn: #f5b93a;
}
/* Moss — forest floor, lime accent (warn pushed orange so the two greens
and the amber never read as one another) */
html[data-theme="moss"] {
--bg: #08120c;
--panel: #101e15;
--border: #1f3728;
--text: #e9f4ea;
--muted: #8fb09a;
--accent: #8fdb3f;
--accent-text: #15250a;
--danger: #ff5a52;
--warn: #ff9f1c;
}
/* Plum — aubergine, purple accent */
html[data-theme="plum"] {
--bg: #110a17;
--panel: #1d1228;
--border: #332042;
--text: #f2e9f8;
--muted: #b195c2;
--accent: #c084fc;
--accent-text: #260a37;
--danger: #ff4d4f;
--warn: #f7b53b;
}
/* Sepia — warm brown room, cool teal accent: the opposite arrangement to
Ember, which is warm throughout */
html[data-theme="sepia"] {
--bg: #12100b;
--panel: #1d1913;
--border: #33291d;
--text: #f4ede0;
--muted: #b3a48b;
--accent: #5eead4;
--accent-text: #06231e;
--danger: #ff5a52;
--warn: #ffc247;
}
/* Blackout — true black for a dark room, where any glow off the screen
carries; everything else is as bright as it can be against it */
html[data-theme="blackout"] {
--bg: #000000;
--panel: #0a0a0a;
--border: #1f1f1f;
--text: #ffffff;
--muted: #9e9e9e;
--accent: #00e5ff;
--accent-text: #00212a;
--danger: #ff3b30;
--warn: #ffcc00;
}
/* Paper — the warm light one, next to Daylight's cool white. Danger and warn
darken, as they must on a light background, and the indigo accent takes
white text. */
html[data-theme="paper"] {
--bg: #f5f1e8;
--panel: #fffdf8;
--border: #ddd5c4;
--text: #1c1a15;
--muted: #6b6455;
--accent: #4f46e5;
--accent-text: #ffffff;
--danger: #c8102e;
--warn: #9a6700;
}
/* Iron — no colour of its own: the accent is near-white, so the only hues on
screen are a module's own and the red/amber that mean something */
html[data-theme="iron"] {
--bg: #0c0c0d;
--panel: #161617;
--border: #2b2b2d;
--text: #f2f2f3;
--muted: #9a9a9d;
--accent: #d8d8dc;
--accent-text: #131314;
--danger: #ff4d4f;
--warn: #ffb224;
}
+630 -39
View File
@@ -12,7 +12,10 @@
* Configure via config/proddash.json (defaults, in the repo), an optional
* proddash.json in the data directory (this machine's overrides), or env vars:
* PORT (default: value in proddash.json, else 24500)
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
* PRODDASH_PASSCODE (a passcode that overrides the stored admin PIN —
* also the way back in if the PIN is ever forgotten)
* PRODDASH_FORGE (overrides `forge`: "github", or a Gitea/Forgejo base
* URL such as http://10.3.8.90:3000)
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
*/
'use strict';
@@ -373,14 +376,17 @@ function mountModule(id) {
// Re-require fresh config on every (re)mount; the module code itself stays cached.
const mod = require(serverPath);
const config = effectiveConfig(id);
// What a module needs to reach the rest of ProdDash — another module's
// routes over loopback, chiefly — without guessing.
const shell = { port: PORT };
if (typeof mod.init === 'function') {
entry.handle = mod.init({ config, log }) || null;
entry.handle = mod.init({ config, log, shell }) || null;
}
if (typeof mod.routes === 'function') {
entry.routes = parseRouteTable(mod.routes({ config, log }));
entry.routes = parseRouteTable(mod.routes({ config, log, shell }));
}
if (typeof mod.tiles === 'function') {
entry.tiles = () => mod.tiles({ config, log });
entry.tiles = () => mod.tiles({ config, log, shell });
}
log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : ''));
} catch (err) {
@@ -460,7 +466,20 @@ function sanitizeTileEntry(raw) {
name: String(raw.name),
description: raw.description ? String(raw.description) : '',
settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : {},
// at most one tile of this entry per dashboard (the picker greys it out)
single: raw.single === true,
};
// An entry may offer its own gear settings — a solo timer wants none of a
// full tile's switches. Same shape as the manifest's instanceSchema/Groups.
for (const key of ['instanceSchema', 'instanceGroups']) {
if (raw[key] && typeof raw[key] === 'object') out[key] = raw[key];
}
// Earlier variant ids this entry stands for, so a tile added under one of
// them (a layout from an older module version) gets this entry's settings.
if (Array.isArray(raw.aliases)) {
const aliases = raw.aliases.map((a) => String(a)).filter(Boolean);
if (aliases.length) out.aliases = aliases;
}
for (const key of ['minSize', 'defaultSize']) {
const size = raw[key];
if (size && Number(size.w) > 0 && Number(size.h) > 0) {
@@ -510,6 +529,8 @@ async function clientManifest(id) {
instanceSchema: man.instanceSchema || {},
instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {},
hasServer: Boolean(man.server),
// capabilities other modules consume ("timers") — see the module guide
provides: Array.isArray(man.provides) ? man.provides.map(String) : [],
config: clientConfig(id),
tiles: await moduleTiles(id),
};
@@ -541,6 +562,203 @@ setInterval(() => {
}
}, 25000).unref();
/* ── one stream per page ─────────────────────────────────────────────
Browsers allow about six HTTP/1.1 connections to a host and an open
EventSource holds one for as long as it lives. A dashboard with six
modules ran out — Safari first — and from then on every further request
(a remounted tile's /state, an avatar) waited in the browser's queue for
ever. So a page opens ONE EventSource, GET /api/stream?s=<keys>, naming
the streams it wants: `slack:/stream`, `pco-plan:/timers/stream`, `shell:`
for the shell's own events. The server opens each module stream for it
over loopback (the module's route is untouched and still serves curl and
other tools), and forwards every frame with the event name prefixed by
its key — `event: slack:/stream|state`. Two frames of its own bracket a
stream's life: `<key>|__open` when the upstream answers, `<key>|__down`
when it ends (it is re-opened after a pause while the page stays). */
const MUX_MAX_STREAMS = 32;
const MUX_RETRY_MS = 3000;
const MUX_RETRY_REFUSED_MS = 10000;
const MUX_PING_MS = 15000;
const MUX_KEY_RE = /^([A-Za-z0-9_-]+):(\/[A-Za-z0-9_\-./]*)?$/;
function parseMuxKeys(search) {
const params = new URLSearchParams(search || '');
const out = [];
const seen = new Set();
for (const raw of String(params.get('s') || '').split(',')) {
const key = raw.trim();
const m = MUX_KEY_RE.exec(key);
if (!m || seen.has(key)) continue;
seen.add(key);
out.push({ key, id: m[1], subPath: m[2] || '' });
if (out.length >= MUX_MAX_STREAMS) break;
}
return out;
}
function handleMuxStream(req, res, search) {
const wanted = parseMuxKeys(search);
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');
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
let closed = false;
const send = (frame) => {
if (closed) return;
try {
res.write(frame);
} catch {
/* the client is gone; 'close' follows */
}
};
// The shell's own events ride along under `shell:` — a stand-in sits in
// shellStreams and rewrites the event line of every frame it is handed.
let shellProxy = null;
const upstreams = [];
for (const entry of wanted) {
if (entry.id === 'shell') {
if (shellProxy) continue;
shellProxy = { write(frame) { send(String(frame).replace(/^event: /m, `event: ${entry.key}|`)); } };
shellStreams.add(shellProxy);
send(`event: ${entry.key}|__open\ndata: {}\n\n`);
continue;
}
upstreams.push({ ...entry, req: null, timer: null, gone: false });
}
function forwardBlock(entry, block) {
let name = 'message';
const data = [];
for (const line of block.split('\n')) {
if (line.startsWith(':')) continue; // the upstream's keepalive; this connection pings on its own
if (line.startsWith('event:')) name = line.slice(6).trim() || 'message';
else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
// id: and retry: belong to the upstream connection, not to the page's
}
if (!data.length) return;
send(`event: ${entry.key}|${name}\n${data.map((d) => `data: ${d}`).join('\n')}\n\n`);
}
function down(entry, why, delay) {
if (closed || entry.gone) return;
entry.gone = true;
send(`event: ${entry.key}|__down\ndata: ${JSON.stringify({ why })}\n\n`);
try { entry.req?.destroy(); } catch { /* already gone */ }
entry.req = null;
entry.timer = setTimeout(() => {
entry.gone = false;
open(entry);
}, delay);
}
function open(entry) {
if (closed) return;
const path = `/api/modules/${encodeURIComponent(entry.id)}${entry.subPath || '/'}`;
const up = http.request({ host: '127.0.0.1', port: PORT, path, method: 'GET', headers: { accept: 'text/event-stream' } }, (upRes) => {
if (upRes.statusCode !== 200 || !/text\/event-stream/.test(String(upRes.headers['content-type'] || ''))) {
upRes.resume();
return down(entry, `HTTP ${upRes.statusCode}`, MUX_RETRY_REFUSED_MS);
}
send(`event: ${entry.key}|__open\ndata: {}\n\n`);
let buf = '';
upRes.setEncoding('utf8');
upRes.on('data', (chunk) => {
buf += chunk;
let i;
while ((i = buf.indexOf('\n\n')) >= 0) {
forwardBlock(entry, buf.slice(0, i));
buf = buf.slice(i + 2);
}
if (buf.length > 1e6) buf = ''; // a frame that never ends is dropped, not kept
});
upRes.on('end', () => down(entry, 'ended', MUX_RETRY_MS));
upRes.on('error', (err) => down(entry, err.message, MUX_RETRY_MS));
});
up.on('error', (err) => down(entry, err.message, MUX_RETRY_MS));
up.end();
entry.req = up;
}
for (const entry of upstreams) open(entry);
const ping = setInterval(() => send(': ping\n\n'), MUX_PING_MS);
req.on('close', () => {
closed = true;
clearInterval(ping);
if (shellProxy) shellStreams.delete(shellProxy);
for (const entry of upstreams) {
clearTimeout(entry.timer);
try { entry.req?.destroy(); } catch { /* already gone */ }
}
});
}
/* ── themes ─────────────────────────────────────────────────────────── */
/* Preset palettes for the whole dashboard, chosen in Admin → Theme. The
variable sets themselves live in public/style.css (html[data-theme="…"]);
this list is the one authority on ids and names, and carries the three
colours the admin page previews a theme with. The choice is a shell
setting — `theme` in the data directory's proddash.json — and reaches open
dashboards live as a `theme` shell event. */
const THEMES = [
{ id: 'booth', name: 'Booth', colors: { bg: '#0b0e12', panel: '#131920', accent: '#2ee59a' } },
{ id: 'harbor', name: 'Harbor', colors: { bg: '#06101f', panel: '#0c1a30', accent: '#2ad4ee' } },
{ id: 'graphite', name: 'Graphite', colors: { bg: '#0f1012', panel: '#17191d', accent: '#6cb4ff' } },
{ id: 'ember', name: 'Ember', colors: { bg: '#120c0a', panel: '#1d1411', accent: '#ff8f3a' } },
{ id: 'daylight', name: 'Daylight', colors: { bg: '#eef1f5', panel: '#ffffff', accent: '#0b8a5f' } },
{ id: 'midnight', name: 'Midnight', colors: { bg: '#0a0b18', panel: '#14162b', accent: '#9d8cff' } },
{ id: 'moss', name: 'Moss', colors: { bg: '#08120c', panel: '#101e15', accent: '#8fdb3f' } },
{ id: 'plum', name: 'Plum', colors: { bg: '#110a17', panel: '#1d1228', accent: '#c084fc' } },
{ id: 'sepia', name: 'Sepia', colors: { bg: '#12100b', panel: '#1d1913', accent: '#5eead4' } },
{ id: 'blackout', name: 'Blackout', colors: { bg: '#000000', panel: '#0a0a0a', accent: '#00e5ff' } },
{ id: 'paper', name: 'Paper', colors: { bg: '#f5f1e8', panel: '#fffdf8', accent: '#4f46e5' } },
{ id: 'iron', name: 'Iron', colors: { bg: '#0c0c0d', panel: '#161617', accent: '#d8d8dc' } },
];
const DEFAULT_THEME = THEMES[0].id;
function themeExists(id) {
return THEMES.some((t) => t.id === id);
}
let currentTheme = themeExists(shellConfig.theme) ? shellConfig.theme : DEFAULT_THEME;
/** What GET /api/theme returns — public, since dashboards need it before anyone logs in. */
function themeView() {
return {
theme: currentTheme,
themes: THEMES.map((t) => ({ id: t.id, name: t.name, colors: { ...t.colors } })),
};
}
/**
* Persist the theme into this machine's proddash.json (every other key there
* — port, adminPasscode, repo… — is kept), remember it, and tell every open
* dashboard and admin page. Throws an httpError for an unknown id or an
* unreadable settings file; the route turns that into the response.
*/
function setTheme(id) {
if (!themeExists(id)) throw httpError(400, `Unknown theme "${id}".`);
const file = path.join(DATA_DIR, 'proddash.json');
let existing = {};
if (fs.existsSync(file)) {
existing = readJson(file, null);
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
throw httpError(500, `${file} is not a JSON object — fix it by hand before changing the theme.`);
}
}
writeJson(file, { ...existing, theme: id });
currentTheme = id;
shellConfig.theme = id;
broadcastShellEvent('theme', { theme: id });
return themeView();
}
/* ── the repo: module catalog and shell updates ──────────────────────── */
/* The admin page installs modules from, and updates the shell against, the
@@ -556,15 +774,53 @@ function parseRepoSetting(value, fallbackBranch) {
}
const REPO = parseRepoSetting(process.env.PRODDASH_REPO || shellConfig.repo, shellConfig.branch)
|| parseRepoSetting('michaelmcgary2008/ProdDash', 'main');
|| parseRepoSetting('michael/ProdDash', 'main');
/**
* Which server the code comes from. GitHub unless `forge` names another —
* give it a Gitea (or Forgejo) base URL, "http://10.3.8.90:3000", and updates
* and module installs come from there instead. The two hosts differ only in
* how an archive URL is spelled: the tarball itself is the same, pax header
* and commit sha included, so everything downstream is untouched.
*/
function parseForge(value) {
const s = String(value || '').trim().replace(/\/+$/, '');
if (!s || s.toLowerCase() === 'github') {
return { kind: 'github', base: 'https://github.com', archiveBase: 'https://codeload.github.com' };
}
if (!/^https?:\/\//i.test(s)) return null;
// A GitHub URL still means codeload for archives.
if (/^https?:\/\/(www\.)?github\.com$/i.test(s)) {
return { kind: 'github', base: s, archiveBase: 'https://codeload.github.com' };
}
return { kind: 'gitea', base: s, archiveBase: s };
}
const FORGE = parseForge(process.env.PRODDASH_FORGE || shellConfig.forge) || parseForge('github');
/** Where branch archives come from; tests point this at a local mock. */
const ARCHIVE_BASE = String(process.env.PRODDASH_ARCHIVE_BASE || 'https://codeload.github.com').replace(/\/+$/, '');
const ARCHIVE_BASE = String(process.env.PRODDASH_ARCHIVE_BASE || FORGE.archiveBase).replace(/\/+$/, '');
const REMOTE_TTL_MS = 10 * 60 * 1000;
const REMOTE_CHECK_MS = 6 * 60 * 60 * 1000;
const MAX_ARCHIVE_BYTES = 80 * 1024 * 1024;
function tarballUrl() {
return `${ARCHIVE_BASE}/${REPO.owner}/${REPO.repo}/tar.gz/${encodeURIComponent(REPO.branch)}`;
const branch = encodeURIComponent(REPO.branch);
return FORGE.kind === 'gitea'
? `${ARCHIVE_BASE}/${REPO.owner}/${REPO.repo}/archive/${branch}.tar.gz`
: `${ARCHIVE_BASE}/${REPO.owner}/${REPO.repo}/tar.gz/${branch}`;
}
/** The page a human should read to see what changed between two commits. */
function forgeCompareUrl(from, to) {
return `${FORGE.base}/${REPO.owner}/${REPO.repo}/compare/${from}...${to}`;
}
/** The commit list for the branch we follow. */
function forgeCommitsUrl() {
const branch = encodeURIComponent(REPO.branch);
return FORGE.kind === 'gitea'
? `${FORGE.base}/${REPO.owner}/${REPO.repo}/commits/branch/${branch}`
: `${FORGE.base}/${REPO.owner}/${REPO.repo}/commits/${branch}`;
}
function httpError(status, message) {
@@ -721,11 +977,11 @@ function shellStatus() {
else if (local.branch !== REPO.branch) blocker = `this checkout is on branch "${local.branch}", not "${REPO.branch}"`;
else if (local.dirty) blocker = 'this checkout has local changes — commit or discard them first';
}
const gh = `https://github.com/${REPO.owner}/${REPO.repo}`;
return {
version: SHELL_VERSION,
repo: `${REPO.owner}/${REPO.repo}`,
branch: REPO.branch,
forge: FORGE.base,
local: { isGit: local.isGit, sha: local.sha, branch: local.branch, dirty: local.dirty },
remote: { version: remote.version, sha: remote.sha, checkedAt: remote.checkedAt, error: remote.error },
updateAvailable: versionBehind || commitsBehind,
@@ -733,8 +989,8 @@ function shellStatus() {
method: local.isGit ? 'git' : 'archive',
updateBlocker: blocker,
changesUrl: local.sha && remote.sha
? `${gh}/compare/${local.sha.slice(0, 12)}...${remote.sha.slice(0, 12)}`
: `${gh}/commits/${REPO.branch}`,
? forgeCompareUrl(local.sha.slice(0, 12), remote.sha.slice(0, 12))
: forgeCommitsUrl(),
};
}
@@ -848,12 +1104,13 @@ function uninstallModule(id) {
/* — updating the shell itself — */
function copyTree(src, dst) {
function copyTree(src, dst, include = null) {
fs.mkdirSync(dst, { recursive: true });
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
if (include && !include(e.name)) continue;
const s = path.join(src, e.name);
const d = path.join(dst, e.name);
if (e.isDirectory()) copyTree(s, d);
if (e.isDirectory()) copyTree(s, d, include);
else fs.copyFileSync(s, d);
}
}
@@ -873,25 +1130,83 @@ async function updateShell() {
const out = git(['pull', '--ff-only', 'origin', REPO.branch], 120000);
console.log('[proddash] git pull:', out.split('\n').filter(Boolean).slice(-2).join(' | '));
} else {
const keep = (rel) => rel === 'config/modules.json' || rel.startsWith('config/layouts/') || rel.startsWith('.git/');
const staging = path.join(ROOT, '.proddash-update');
fs.rmSync(staging, { recursive: true, force: true });
for (const [rel, data] of remote.files) {
if (!safeRelPath(rel) || keep(rel)) continue;
if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue;
const target = path.join(staging, rel);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, data);
}
if (!fs.existsSync(path.join(staging, 'server.js'))) {
const newServer = path.join(staging, 'server.js');
if (!fs.existsSync(newServer)) {
fs.rmSync(staging, { recursive: true, force: true });
throw httpError(502, 'The downloaded archive does not look like ProdDash (no server.js).');
}
// Nothing is swapped in until the new server at least parses. A file that
// arrived truncated or mangled would otherwise be found only by a process
// that cannot start — the one state this app cannot talk its way out of.
try {
execFileSync(process.execPath, ['--check', newServer], { timeout: 30000, stdio: ['ignore', 'ignore', 'pipe'] });
} catch (err) {
fs.rmSync(staging, { recursive: true, force: true });
// node --check leads with the staging path and a caret diagram; the
// line that names the fault is the one worth repeating.
const lines = String(err?.stderr || err?.message || err).split('\n').map((l) => l.trim());
const why = lines.find((l) => /^[A-Za-z]*Error\b/.test(l)) || 'it does not parse';
throw httpError(502, `The downloaded ProdDash will not run — nothing was changed (${why}).`);
}
keepPreviousCopy();
copyTree(staging, ROOT);
fs.rmSync(staging, { recursive: true, force: true });
}
return { from: SHELL_VERSION, to: remote.version || '', method: status.method };
}
/* ── what an update must not touch ───────────────────────────────────
Anything inside the data directory. Normally it sits well outside the app
folder and none of this applies, but when it cannot (an unwritable
Application Support, or PRODDASH_DATA_DIR aimed inside the app) the data
directory IS the app's config/ — and an update would then write the repo's
shipped defaults over this machine's own proddash.json, taking the admin
PIN, the theme and the forge with it. The two legacy names stay listed for
an install whose state never moved out. */
const DATA_DIR_INSIDE_ROOT = (() => {
const rel = path.relative(ROOT, DATA_DIR);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return '';
return rel.split(path.sep).join('/') + '/';
})();
function isKeptOnUpdate(rel) {
if (rel.startsWith('.git/')) return true;
if (rel === 'config/modules.json' || rel.startsWith('config/layouts/')) return true;
return Boolean(DATA_DIR_INSIDE_ROOT) && `${rel}/`.startsWith(DATA_DIR_INSIDE_ROOT);
}
/* ── the way back ────────────────────────────────────────────────────
The copy being replaced is set aside first, so there is always one known
version to return to. The launcher restores it if the new one will not
stay up (see ServerController); by hand it is a rename. It is replaced by
the next update, never accumulated. */
function previousCopyPath() {
return `${ROOT}.previous`;
}
function keepPreviousCopy() {
const previous = previousCopyPath();
try {
fs.rmSync(previous, { recursive: true, force: true });
copyTree(ROOT, previous, (rel) => rel !== '.proddash-update' && rel !== '.git');
fs.writeFileSync(path.join(previous, '.proddash-version'), SHELL_VERSION);
console.log(`[proddash] kept ${SHELL_VERSION} at ${previous} in case the new one will not run`);
} catch (err) {
// Worth saying, not worth refusing the update over.
console.warn('[proddash] could not set the current copy aside:', err?.message || err);
}
}
/**
* Exit so the new code runs. Exit code 75 tells a launcher loop to start us
* again; when nothing is supervising (PRODDASH_LAUNCHER unset) we start our
@@ -922,12 +1237,109 @@ function restartServer(reason) {
/* ── admin API ──────────────────────────────────────────────────────── */
/* Admin is behind a 6-digit PIN, set in the admin page and kept in this
machine's proddash.json as a scrypt hash — never in the clear, because that
file gets copied between machines. A dashboard needs no PIN; only /admin
and the admin API do. Two ways back in if it is ever forgotten: set
PRODDASH_PASSCODE in the environment, or delete "adminPin" from
proddash.json in the data directory and restart. `adminPasscode` (the older
free-text setting) still works so an existing install keeps running. */
const PIN_RE = /^\d{6}$/;
const PIN_MAX_WRONG = 5;
const PIN_LOCKOUT_MS = 30000;
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
/** Session token: regenerating it every boot is fine — admins just re-enter the passcode. */
/** Session token: regenerating it every boot is fine — admins just re-enter the PIN. */
const adminToken = crypto.randomBytes(24).toString('hex');
/** The stored PIN, as "scrypt$<salt>$<hash>"; '' when admin is open. */
let adminPinHash = String(shellConfig.adminPin || '');
const pinAttempts = new Map(); // ip → { count, lockedUntil }
function hashPin(pin, salt = crypto.randomBytes(16).toString('hex')) {
return `scrypt$${salt}$${crypto.scryptSync(String(pin), salt, 32).toString('hex')}`;
}
/** Constant-time check of a PIN against the stored hash. */
function pinMatches(given, stored) {
const parts = String(stored || '').split('$');
if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
const expected = Buffer.from(parts[2], 'hex');
const actual = crypto.scryptSync(String(given), parts[1], expected.length);
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
}
/** Is a credential required at all? */
function adminLocked() {
return Boolean(ADMIN_PASSCODE || adminPinHash);
}
/** True when `given` is the credential in force (the env passcode, or the PIN). */
function credentialOk(given) {
const s = String(given ?? '');
if (!s) return false;
if (ADMIN_PASSCODE) {
const a = Buffer.from(s);
const b = Buffer.from(ADMIN_PASSCODE);
if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true;
}
return adminPinHash ? pinMatches(s, adminPinHash) : false;
}
function clientIp(req) {
return String(req.socket?.remoteAddress || 'unknown');
}
/** Seconds of lockout left for this address, or 0. */
function lockoutLeft(ip) {
const a = pinAttempts.get(ip);
if (!a || !a.lockedUntil) return 0;
const left = a.lockedUntil - Date.now();
if (left <= 0) {
pinAttempts.delete(ip);
return 0;
}
return Math.ceil(left / 1000);
}
function noteWrongPin(ip) {
const a = pinAttempts.get(ip) || { count: 0, lockedUntil: 0 };
a.count += 1;
if (a.count >= PIN_MAX_WRONG) {
a.count = 0;
a.lockedUntil = Date.now() + PIN_LOCKOUT_MS;
console.warn(`[proddash] ${PIN_MAX_WRONG} wrong admin PINs from ${ip} — locked out for ${PIN_LOCKOUT_MS / 1000} s`);
}
pinAttempts.set(ip, a);
}
/**
* Store a new PIN (6 digits), or '' to open admin up again. Keeps every other
* key in this machine's proddash.json. Throws an httpError the route reports.
*/
function setAdminPin(pin) {
const next = String(pin ?? '');
if (next && !PIN_RE.test(next)) throw httpError(400, 'The PIN must be exactly 6 digits.');
const file = path.join(DATA_DIR, 'proddash.json');
let existing = {};
if (fs.existsSync(file)) {
existing = readJson(file, null);
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
throw httpError(500, `${file} is not a JSON object — fix it by hand before changing the PIN.`);
}
}
const hash = next ? hashPin(next) : '';
const out = { ...existing };
if (hash) out.adminPin = hash;
else delete out.adminPin;
writeJson(file, out);
adminPinHash = hash;
shellConfig.adminPin = hash;
pinAttempts.clear();
console.log(`[proddash] admin PIN ${hash ? 'set' : 'removed — admin is open again'}`);
return { pinSet: Boolean(hash) };
}
function isAuthed(req) {
if (!ADMIN_PASSCODE) return true;
if (!adminLocked()) return true;
const cookies = String(req.headers.cookie || '');
return cookies.split(';').some((c) => c.trim() === 'proddash_admin=' + adminToken);
}
@@ -935,7 +1347,7 @@ function isAuthed(req) {
/** Guard a state-changing admin route. Returns true when the request was refused. */
function refuseAdminWrite(req, res) {
if (!isAuthed(req)) {
sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true });
sendJson(res, 401, { error: 'Enter the admin PIN first.', authRequired: true });
return true;
}
// Requiring application/json forces a CORS preflight that a hostile page
@@ -996,6 +1408,9 @@ function applyConfigPatch(id, patch) {
}
if (type === 'number') value = Number(value) || 0;
else if (type === 'boolean' || type === 'switch') value = Boolean(value);
else if (type === 'multiselect') {
value = (Array.isArray(value) ? value : []).map((v) => String(v)).filter(Boolean);
}
else if (type === 'endpoint') {
const src = value && typeof value === 'object' ? value : {};
value = {
@@ -1017,10 +1432,19 @@ async function handleAdminApi(req, res, urlPath) {
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
if (!ADMIN_PASSCODE) return sendJson(res, 200, { ok: true, authRequired: false });
if (String(body.passcode || '') !== ADMIN_PASSCODE) {
return sendJson(res, 403, { error: 'Wrong passcode.' });
if (!adminLocked()) return sendJson(res, 200, { ok: true, authRequired: false });
const ip = clientIp(req);
const left = lockoutLeft(ip);
if (left) return sendJson(res, 429, { error: `Too many wrong PINs — try again in ${left} s.`, retryAfter: left });
// `passcode` is what the older admin page sent; `pin` is what this one does.
if (!credentialOk(body.pin ?? body.passcode)) {
noteWrongPin(ip);
const now = lockoutLeft(ip);
return sendJson(res, 403, now
? { error: `Too many wrong PINs — try again in ${now} s.`, retryAfter: now }
: { error: 'Wrong PIN.' });
}
pinAttempts.delete(ip);
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
@@ -1029,12 +1453,50 @@ async function handleAdminApi(req, res, urlPath) {
return res.end(JSON.stringify({ ok: true }));
}
if (urlPath === '/api/admin/pin' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
// Changing a credential re-asserts it, so a browser left open on the
// admin page can't be used to take the machine over.
if (adminPinHash) {
const ip = clientIp(req);
const left = lockoutLeft(ip);
if (left) return sendJson(res, 429, { error: `Too many wrong PINs — try again in ${left} s.`, retryAfter: left });
if (!pinMatches(String(body.current ?? ''), adminPinHash)) {
noteWrongPin(ip);
return sendJson(res, 403, { error: 'That is not the current PIN.' });
}
pinAttempts.delete(ip);
}
try {
const result = setAdminPin(body.pin);
// Whoever just set it has proved they are allowed, so hand them the
// session rather than bouncing them to the unlock card they just built.
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
'Cache-Control': 'no-store',
});
return res.end(JSON.stringify(result));
} catch (err) {
return sendJson(res, err.status || 500, { error: err.message || 'Could not save the PIN.' });
}
}
if (urlPath === '/api/admin/state' && req.method === 'GET') {
if (!isAuthed(req)) {
return sendJson(res, 401, { authRequired: true, authed: false });
}
return sendJson(res, 200, {
authRequired: Boolean(ADMIN_PASSCODE),
authRequired: adminLocked(),
// The env passcode can't be changed from here; the page says so.
pinSet: Boolean(adminPinHash),
pinFromEnv: Boolean(ADMIN_PASSCODE),
authed: true,
version: SHELL_VERSION,
dataDir: DATA_DIR,
@@ -1109,6 +1571,22 @@ async function handleAdminApi(req, res, urlPath) {
return sendJson(res, 200, { ok: true, module: adminModuleView(id) });
}
/* — theme: { "theme": "<id>" }, validated against THEMES — */
if (urlPath === '/api/admin/theme' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
try {
return sendJson(res, 200, { ok: true, ...setTheme(String(body.theme ?? '')) });
} catch (err) {
return sendJson(res, err.status || 500, { error: err.message || String(err) });
}
}
sendJson(res, 404, { error: 'Unknown admin endpoint.' });
}
@@ -1123,19 +1601,72 @@ function layoutPath(name) {
return path.join(LAYOUTS_DIR, name + '.json');
}
/** Keep only the fields a layout is made of; everything else is dropped. */
/** The copy a save replaced — one back, kept until the next save replaces it.
Names cannot contain a dot (layoutNameOk), so this can never be a real
layout's own file. */
function previousLayoutPath(name) {
return path.join(LAYOUTS_DIR, name + '.previous.json');
}
/* A saved layout is `{ v, autoHideHeads, tiles }`. The fields the shell knows
are checked for shape; anything else a tile carries rides along untouched.
That second part is the lesson of a bug: this function used to rebuild each
tile from the fields it knew and drop the rest, and for seventeen days it
dropped `variant` — every solo timer saved to a server came back as "All
timers". The server is the part of ProdDash that lags (a booth updates
weeks after the dev machine), so it must never be the one deciding which
tile fields exist. Retired fields are the one exception: they are dropped
so an old file does not haunt a new client. */
const LAYOUT_VERSION = 2;
const MAX_LAYOUT_TILES = 100;
const KNOWN_TILE_FIELDS = new Set(['id', 'module', 'x', 'y', 'w', 'h', 'settings', 'variant', 'title', 'headPinned', 'minSize']);
const RETIRED_TILE_FIELDS = new Set(['headHidden']);
/** An unknown tile field is kept when it is a sane key holding small, plain JSON. */
function keepUnknownTileField(key, value) {
if (!/^[A-Za-z][A-Za-z0-9_]{0,39}$/.test(key)) return false;
switch (typeof value) {
case 'string': return value.length <= 2000;
case 'number': return Number.isFinite(value);
case 'boolean': return true;
case 'object':
if (value === null) return false;
try { return JSON.stringify(value).length <= 20000; } catch { return false; }
default: return false;
}
}
function sanitizeLayout(raw) {
const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : [];
const tiles = Array.isArray(raw?.tiles) ? raw.tiles : [];
return {
tiles: tiles.map((t) => ({
id: String(t?.id || ''),
module: String(t?.module || ''),
x: Number(t?.x) || 0,
y: Number(t?.y) || 0,
w: Number(t?.w) || 1,
h: Number(t?.h) || 1,
settings: t?.settings && typeof t.settings === 'object' ? t.settings : {},
})),
v: LAYOUT_VERSION,
// Whether tile title bars auto-hide is part of how a dashboard is
// arranged, so it travels with the layout like the tiles themselves.
autoHideHeads: Boolean(raw?.autoHideHeads),
tiles: tiles.map((t) => {
const tile = {
id: String(t?.id || ''),
module: String(t?.module || ''),
x: Number(t?.x) || 0,
y: Number(t?.y) || 0,
w: Number(t?.w) || 1,
h: Number(t?.h) || 1,
settings: t?.settings && typeof t.settings === 'object' ? t.settings : {},
// Which picker entry the tile came from, and what it was called.
variant: String(t?.variant || ''),
title: String(t?.title || ''),
headPinned: Boolean(t?.headPinned),
};
if (Number(t?.minSize?.w) > 0 && Number(t?.minSize?.h) > 0) {
tile.minSize = { w: Number(t.minSize.w), h: Number(t.minSize.h) };
}
for (const [key, value] of Object.entries(t && typeof t === 'object' ? t : {})) {
if (KNOWN_TILE_FIELDS.has(key) || RETIRED_TILE_FIELDS.has(key)) continue;
if (keepUnknownTileField(key, value)) tile[key] = value;
}
return tile;
}),
};
}
@@ -1147,17 +1678,23 @@ async function handleLayoutsApi(req, res, urlPath) {
} catch { /* no layouts dir yet */ }
const layouts = [];
for (const file of files.sort()) {
if (file.endsWith('.previous.json')) continue; // a save's one-back copy, listed on its layout
const name = file.slice(0, -5);
const data = readJson(path.join(LAYOUTS_DIR, file), null);
if (!data) continue;
let updated = null;
try { updated = fs.statSync(path.join(LAYOUTS_DIR, file)).mtime.toISOString(); } catch { /* fine */ }
layouts.push({ name, tiles: Array.isArray(data.tiles) ? data.tiles.length : 0, updated });
layouts.push({
name,
tiles: Array.isArray(data.tiles) ? data.tiles.length : 0,
updated,
hasPrevious: fs.existsSync(previousLayoutPath(name)),
});
}
return sendJson(res, 200, { layouts });
}
const m = urlPath.match(/^\/api\/layouts\/([^/]+)(\/rename)?$/);
const m = urlPath.match(/^\/api\/layouts\/([^/]+)(\/rename|\/restore)?$/);
if (!m) return sendJson(res, 404, { error: 'Unknown endpoint.' });
let name;
try {
@@ -1190,25 +1727,34 @@ async function handleLayoutsApi(req, res, urlPath) {
}
const layout = sanitizeLayout(body.layout);
if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' });
if (layout.tiles.length > MAX_LAYOUT_TILES) {
// Saying no beats quietly saving the first hundred and losing the rest.
return sendJson(res, 400, { error: `A layout can hold ${MAX_LAYOUT_TILES} tiles; this one has ${layout.tiles.length}.` });
}
const replaced = fs.existsSync(layoutPath(name));
try {
// The copy being replaced is kept one back, so a save over the wrong
// name is a restore away rather than gone.
if (replaced) fs.copyFileSync(layoutPath(name), previousLayoutPath(name));
writeJson(layoutPath(name), layout);
} catch (err) {
return sendJson(res, 500, { error: `Could not save ${layoutPath(name)}: ${err?.message || err}` });
}
return sendJson(res, 200, { ok: true, name });
return sendJson(res, 200, { ok: true, name, replaced });
}
if (!m[2] && req.method === 'DELETE') {
if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true });
if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin PIN first.', authRequired: true });
try {
fs.unlinkSync(layoutPath(name));
} catch {
return sendJson(res, 404, { error: `No layout named "${name}".` });
}
fs.rmSync(previousLayoutPath(name), { force: true });
return sendJson(res, 200, { ok: true });
}
if (m[2] && req.method === 'POST') {
if (m[2] === '/rename' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
let body = {};
try {
@@ -1232,6 +1778,25 @@ async function handleLayoutsApi(req, res, urlPath) {
return sendJson(res, 200, { ok: true, name: next });
}
// Put back the copy the last save replaced. The two swap, so a restore is
// itself one restore away from being undone.
if (m[2] === '/restore' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
const current = layoutPath(name);
const previous = previousLayoutPath(name);
if (!fs.existsSync(previous)) return sendJson(res, 404, { error: `"${name}" has no previous version to go back to.` });
try {
const swapTmp = current + '.swap';
const hadCurrent = fs.existsSync(current);
if (hadCurrent) fs.renameSync(current, swapTmp);
fs.renameSync(previous, current);
if (hadCurrent) fs.renameSync(swapTmp, previous);
} catch (err) {
return sendJson(res, 500, { error: 'Restore failed: ' + (err?.message || err) });
}
return sendJson(res, 200, { ok: true, name });
}
sendJson(res, 405, { error: 'Method not allowed.' });
}
@@ -1352,6 +1917,16 @@ function handleRequest(req, res) {
return;
}
/* — theme: public (dashboards need it without a passcode); changed via POST /api/admin/theme — */
if (urlPath === '/api/theme' && req.method === 'GET') {
return sendJson(res, 200, themeView());
}
/* — every stream a page wants, over one connection — */
if (urlPath === '/api/stream' && req.method === 'GET') {
return handleMuxStream(req, res, search);
}
/* — shell events: open dashboards learn about admin changes live — */
if (urlPath === '/api/events' && req.method === 'GET') {
res.writeHead(200, {
@@ -1456,7 +2031,7 @@ server.on('listening', () => {
? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)') + (manifests.get(id).incompatible ? ' (needs newer ProdDash)' : '')).join(', ')
: ' Modules : none installed');
console.log(' Settings : ' + DATA_DIR + (DATA_DIR === REPO_CONFIG_DIR ? ' (inside the app folder)' : ''));
console.log(' Repo : ' + REPO.owner + '/' + REPO.repo + '@' + REPO.branch);
console.log(' Repo : ' + REPO.owner + '/' + REPO.repo + '@' + REPO.branch + ' (' + FORGE.base + ')');
});
// A restart hands the port over from the exiting ProdDash: wait for it.
@@ -1474,6 +2049,22 @@ server.on('error', (err) => {
server.listen(PORT, '0.0.0.0');
/* An update proves itself by staying up. Once this version has, the copy it
replaced is dropped — so `app.previous` existing always means "the last
update has not proven itself yet", which is exactly what the launcher
should roll back to and nothing more. */
const UPDATE_PROVEN_AFTER_MS = 90000;
setTimeout(() => {
const previous = previousCopyPath();
if (!fs.existsSync(previous)) return;
try {
fs.rmSync(previous, { recursive: true, force: true });
console.log(`[proddash] ${SHELL_VERSION} has stayed up — dropped the copy it replaced`);
} catch (err) {
console.warn('[proddash] could not drop the previous copy:', err?.message || err);
}
}, UPDATE_PROVEN_AFTER_MS).unref();
// Update check: shortly after boot, then every few hours (the admin page
// shows the result; nothing is installed without someone clicking).
if (!process.env.PRODDASH_NO_REMOTE_CHECK) {
+157
View File
@@ -0,0 +1,157 @@
'use strict';
/**
* Round-trips a layout through the server's layouts API and checks that
* nothing is lost on the way: a fully-populated tile, a tile field the server
* has never heard of, the one-back copy a save leaves behind, restore, the
* tile cap and delete.
*
* node tools/layout-roundtrip.test.js
*
* Plain Node, no dependencies. Starts server.js on a spare port with a
* temporary data directory, talks to it over HTTP, exits non-zero on the
* first failure and always stops the server it started.
*
* It exists because of one bug: for seventeen days the server rebuilt each
* saved tile from the fields it knew and dropped the rest — including
* `variant`, so every solo timer saved to a booth came back as "All timers".
* A field added on the client must survive a server that has not heard of
* it, and this is where that is checked.
*/
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const fs = require('node:fs');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
function freePort() {
return new Promise((resolve, reject) => {
const probe = net.createServer();
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address();
probe.close(() => resolve(port));
});
probe.on('error', reject);
});
}
async function waitFor(url, ms = 15000) {
const deadline = Date.now() + ms;
let last = '';
while (Date.now() < deadline) {
try {
const r = await fetch(url);
if (r.ok) return;
last = 'HTTP ' + r.status;
} catch (err) {
last = err.message;
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error('server did not come up: ' + last);
}
(async () => {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-layout-test-'));
const port = await freePort();
const child = spawn(process.execPath, ['server.js'], {
cwd: ROOT,
env: { ...process.env, PRODDASH_DATA_DIR: dataDir, PORT: String(port), PRODDASH_NO_REMOTE_CHECK: '1', PRODDASH_LAUNCHER: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let log = '';
child.stdout.on('data', (d) => { log += d; });
child.stderr.on('data', (d) => { log += d; });
const base = `http://127.0.0.1:${port}`;
const api = async (p, init = {}) => {
const r = await fetch(base + p, { headers: { 'Content-Type': 'application/json' }, ...init });
return { status: r.status, body: await r.json().catch(() => ({})) };
};
const layoutsDir = path.join(dataDir, 'layouts');
let failed = false;
try {
await waitFor(base + '/api/theme');
// Every field the shell writes today, one it will write tomorrow, and one
// it has stopped writing.
const tile = {
id: 't1', module: 'propresenter-timers', x: 4, y: 2, w: 3, h: 2,
settings: { showNames: false, __tint: '#112233' },
variant: 'solo:pco-plan:service', title: 'Service', headPinned: true, minSize: { w: 2, h: 1 },
futureField: 'kept', futureFlag: true, futureBox: { a: 1 },
headHidden: true,
};
let r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { autoHideHeads: true, tiles: [tile] } }) });
assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body));
assert.equal(r.body.replaced, false, 'a first save replaces nothing');
r = await api('/api/layouts/Booth%20A');
assert.equal(r.status, 200);
const got = r.body.layout;
assert.equal(got.v, 2, 'the file carries a schema version');
assert.equal(got.autoHideHeads, true);
const t = got.tiles[0];
for (const k of ['id', 'module', 'x', 'y', 'w', 'h', 'variant', 'title', 'headPinned']) assert.deepEqual(t[k], tile[k], k);
assert.deepEqual(t.settings, tile.settings, 'settings');
assert.deepEqual(t.minSize, tile.minSize, 'minSize');
assert.equal(t.futureField, 'kept', 'an unknown string field survives');
assert.equal(t.futureFlag, true, 'an unknown boolean survives');
assert.deepEqual(t.futureBox, { a: 1 }, 'an unknown object survives');
assert.equal('headHidden' in t, false, 'a retired field is dropped');
// Saving over the name keeps the copy it replaced.
r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { tiles: [{ ...tile, title: 'Service v2' }] } }) });
assert.equal(r.status, 200);
assert.equal(r.body.replaced, true, 'the second save reports a replacement');
assert.ok(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), 'the replaced copy is kept');
r = await api('/api/layouts');
assert.deepEqual(r.body.layouts.map((l) => [l.name, l.hasPrevious]), [['Booth A', true]],
'the listing shows one layout with a previous copy — not two layouts');
// Restore swaps them, so a second restore undoes the first. Admin is open
// here (no PIN), which is what a fresh data directory gives.
r = await api('/api/layouts/Booth%20A/restore', { method: 'POST', body: '{}' });
assert.equal(r.status, 200, 'restore: ' + JSON.stringify(r.body));
r = await api('/api/layouts/Booth%20A');
assert.equal(r.body.layout.tiles[0].title, 'Service', 'restore brings the earlier save back');
r = await api('/api/layouts/Booth%20A/restore', { method: 'POST', body: '{}' });
assert.equal(r.status, 200);
r = await api('/api/layouts/Booth%20A');
assert.equal(r.body.layout.tiles[0].title, 'Service v2', 'restoring again goes forward again');
r = await api('/api/layouts/Never%20Saved/restore', { method: 'POST', body: '{}' });
assert.equal(r.status, 404, 'nothing to restore is said, not faked');
// Too many tiles is refused, not trimmed.
const many = Array.from({ length: 101 }, (_, i) => ({ ...tile, id: 't' + i }));
r = await api('/api/layouts/Too%20Big', { method: 'PUT', body: JSON.stringify({ layout: { tiles: many } }) });
assert.equal(r.status, 400);
assert.match(String(r.body.error || ''), /100 tiles/, 'the refusal names the cap');
assert.equal(fs.existsSync(path.join(layoutsDir, 'Too Big.json')), false, 'nothing was written');
// Delete takes the previous copy with it.
r = await api('/api/layouts/Booth%20A', { method: 'DELETE' });
assert.equal(r.status, 200);
assert.equal(fs.existsSync(path.join(layoutsDir, 'Booth A.json')), false);
assert.equal(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), false, 'the previous copy is gone too');
r = await api('/api/layouts');
assert.deepEqual(r.body.layouts, [], 'nothing left listed');
console.log('layout round-trip: every check passed');
} catch (err) {
failed = true;
console.error('FAIL:', err.message);
console.error('--- last of the server log ---\n' + log.slice(-2000));
} finally {
child.kill('SIGTERM');
await new Promise((r) => setTimeout(r, 400));
fs.rmSync(dataDir, { recursive: true, force: true });
}
process.exit(failed ? 1 : 0);
})();
+23
View File
@@ -11,6 +11,10 @@
* GET /services/v2/service_types every service type, with its
* parent folder relationship
* GET /services/v2/service_types/{id}
* GET /services/v2/service_types/{id}/item_note_categories
* the note categories the
* items below use, plus one
* ("Stage") no note uses yet
* GET /services/v2/service_types/{id}/plans ?filter=future|past, order,
* per_page — today's plan,
* next week's and last week's
@@ -129,6 +133,22 @@ const ITEMS = [
{ id: 'i13', type: 'media', title: 'Walk-out Loop', pos: 'post', length: 600, notes: [['Video', 'Announcements loop until the room clears']] },
];
/**
* Item note categories, per service type in Planning Center: every category
* the items above use, in the order the mock account lists them, plus one no
* note uses yet — a tile that has never chosen shows it on as well.
*/
const NOTE_CATEGORIES = ['Audio', 'Video', 'Lighting', 'ProPresenter', 'Stage'];
function noteCategoryRecord(name, index, stId) {
return {
type: 'ItemNoteCategory',
id: `${stId}-nc${index + 1}`,
attributes: { name, sequence: index + 1, frequently_used: index < 2, deleted_at: null },
relationships: { service_type: { data: { type: 'ServiceType', id: stId } } },
};
}
function itemsDocument(planId, include) {
const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean));
const data = [];
@@ -246,6 +266,9 @@ const server = http.createServer((req, res) => {
const st = SERVICE_TYPES.find((x) => x.id === parts[1]);
if (!st) return notFound(res);
if (parts.length === 2) return send(res, 200, { data: serviceTypeRecord(st) });
if (parts[2] === 'item_note_categories' && parts.length === 3) {
return collection(res, NOTE_CATEGORIES.map((name, i) => noteCategoryRecord(name, i, st.id)), url);
}
if (parts[2] !== 'plans') return notFound(res);
const plans = plansFor(st.id);
if (parts.length === 3) {
+417
View File
@@ -0,0 +1,417 @@
/**
* Mock Slack Web API for developing the slack module without a workspace.
*
* node tools/slack-mock.js [port] (default 24716)
* SLACK_API_BASE=http://127.0.0.1:24716/api node server.js
*
* Then, in /admin → Slack, paste any token that starts with xoxp- (a user
* token: auth.test answers as the location account "Apollo Beach") or xoxb-
* (a bot token: bot_id set, user "proddash"); anything else is invalid_auth.
* Two more tokens exercise the error paths: xoxp-revoked → token_revoked,
* xoxp-noscope → missing_scope (needed channels:history) on history reads.
*
* Methods, all under /api/<method>, POST form or JSON (GET works too):
* auth.test identity for the token (see above)
* conversations.list #production, #ops, #campus-leads (private) as
* member; #announcements not a member; paginated
* conversations.info one channel, with is_member
* conversations.history newest-first window (limit); not_in_channel for
* #announcements
* conversations.replies a thread's parent + replies
* users.info display names and profile images (SVG squares
* served by this mock under /img/u/<id>.svg)
* chat.postMessage appends to the channel (echoes `username` for
* bots as a bot_message); text containing "!429"
* answers HTTP 429 with Retry-After: 3
* emoji.list four custom emoji: two images (/img/e/<name>.svg),
* an alias of a standard emoji, an alias of a custom
* reactions.add/.remove on a message (or reply) by channel + timestamp;
* already_reacted / no_reaction / message_not_found
* as Slack answers them; name "nope" → invalid_name
*
* #production is seeded with mrkdwn, mentions, a link, a thread, a bot
* attachment and a file; it gains a new fake message every ~8 s, edits one
* every ~25 s, deletes one every ~40 s and adds a thread reply every ~30 s.
* #ops gets a quieter trickle (every ~20 s), so a dashboard with both
* transcripts shows two channels moving at their own pace — and a
* chat.postMessage to either lands in that channel's history, which is how
* a send to the *selected* transcript can be watched arriving in the right
* tile. Every request is logged.
*
* Multi-channel dev flow: run this, then ProdDash with SLACK_API_BASE set,
* paste xoxp-anything in /admin → Slack, tick #production and #ops under
* Channels, Apply, and add both transcripts plus Send message from +.
*/
'use strict';
const http = require('http');
const PORT = Number.parseInt(process.argv[2] || '24716', 10);
/* ── data ───────────────────────────────────────────────────────────── */
const USERS = {
U001: { name: 'apollo.beach', real_name: 'Apollo Beach', display_name: 'Apollo Beach' },
U002: { name: 'michael', real_name: 'Michael McGary', display_name: 'Michael' },
U003: { name: 'sarah.lee', real_name: 'Sarah Lee', display_name: 'Sarah' },
U004: { name: 'north.attleboro', real_name: 'North Attleboro', display_name: 'North Attleboro' },
U005: { name: 'dan.k', real_name: 'Dan Kowalski', display_name: 'Dan K' },
UBOT: { name: 'proddash', real_name: 'ProdDash', display_name: '', is_bot: true },
};
const CHANNELS = [
{ id: 'C001', name: 'production', is_private: false, is_member: true },
{ id: 'C002', name: 'ops', is_private: false, is_member: true },
{ id: 'G001', name: 'campus-leads', is_private: true, is_member: true },
{ id: 'C003', name: 'announcements', is_private: false, is_member: false },
];
let tsCounter = 0;
function ts(offsetMs = 0) {
tsCounter += 1;
return `${Math.floor((Date.now() + offsetMs) / 1000)}.${String(tsCounter).padStart(6, '0')}`;
}
/** channel id -> messages (ascending); replies live inside their parent. */
const history = new Map();
for (const c of CHANNELS) history.set(c.id, []);
function push(channelId, msg) {
const list = history.get(channelId);
const m = { type: 'message', ts: msg.ts || ts(), ...msg };
list.push(m);
if (list.length > 400) list.splice(0, list.length - 400);
return m;
}
/** A message or a reply by timestamp, anywhere in a channel. */
function findMessage(channelId, stamp) {
for (const m of history.get(channelId) || []) {
if (m.ts === stamp) return m;
for (const r of m.replies || []) if (r.ts === stamp) return r;
}
return null;
}
/** Slack's reactions array: [{ name, users, count }], edited in place. */
function addReaction(m, name, userId) {
m.reactions = m.reactions || [];
let r = m.reactions.find((x) => x.name === name);
if (r && r.users.includes(userId)) return 'already_reacted';
if (!r) {
r = { name, users: [], count: 0 };
m.reactions.push(r);
}
r.users.push(userId);
r.count = r.users.length;
return '';
}
function removeReaction(m, name, userId) {
const r = (m.reactions || []).find((x) => x.name === name);
if (!r || !r.users.includes(userId)) return 'no_reaction';
r.users = r.users.filter((u) => u !== userId);
r.count = r.users.length;
if (!r.count) m.reactions = m.reactions.filter((x) => x !== r);
if (!m.reactions.length) delete m.reactions;
return '';
}
/* A square SVG with an initial, one hue per id — the mock's stand-in for a
profile image or a custom emoji picture. */
const IMAGE_HUES = { U001: 150, U002: 210, U003: 330, U004: 30, U005: 270, UBOT: 0, party_parrot: 120, waters: 200 };
function squareSvg(id, letter) {
const hue = IMAGE_HUES[id] ?? 180;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72"><rect width="72" height="72" rx="18" fill="hsl(${hue} 50% 45%)"/><text x="36" y="47" text-anchor="middle" font-family="system-ui, sans-serif" font-size="34" font-weight="700" fill="#fff">${letter}</text></svg>`;
}
const CUSTOM_EMOJI = {
party_parrot: (port) => `http://127.0.0.1:${port}/img/e/party_parrot.svg`,
waters: (port) => `http://127.0.0.1:${port}/img/e/waters.svg`,
thumbsup_all: () => 'alias:+1',
parrot: () => 'alias:party_parrot',
};
function seed() {
const min = 60000;
const at = (minsAgo) => ts(-minsAgo * min);
push('C001', { ts: at(58), user: 'U003', subtype: 'channel_join', text: '<@U003> has joined the channel' });
push('C001', { ts: at(55), user: 'U002', text: 'Morning all — doors at 9:15, first service 10:00. Run of show is in <#C002|ops>.' });
push('C001', { ts: at(50), user: 'U001', text: 'Apollo Beach is up. Stream key checked, ProPresenter online :white_check_mark:' });
push('C001', { ts: at(47), user: 'U004', text: 'North Attleboro here — we have *no audio* on the sermon feed yet, working on it' });
push('C001', { ts: at(44), user: 'U002', text: '<@U004> try the Dante patch — `Sermon Feed L/R` should be on 31/32. _Ping me if not._' });
const parent = push('C001', { ts: at(40), user: 'U003', text: 'Countdown video is the new one this week, right? ~old one~ &lt;-- not this', reply_count: 2, latest_reply: '' });
parent.replies = [
{ type: 'message', ts: at(39), user: 'U002', text: 'Yes — "Countdown 5 min (Sept)" in the media bin', thread_ts: parent.ts },
{ type: 'message', ts: at(38), user: 'U003', text: 'Got it :+1:', thread_ts: parent.ts },
];
parent.latest_reply = parent.replies[1].ts;
push('C001', {
ts: at(35), subtype: 'bot_message', bot_id: 'B002', username: 'Planning Center', text: '',
attachments: [{ fallback: 'Plan updated: Sunday Service — Sept 14 (2 items changed)', title: 'Plan updated', text: 'Sunday Service — Sept 14 (2 items changed)' }],
});
push('C001', { ts: at(30), user: 'U005', text: 'Lower thirds for the announcements: <https://waterschurch.org/plan|this week’s plan>', files: [{ id: 'F001', name: 'lower-thirds-0914.png', title: 'lower-thirds-0914.png' }] });
const fixed = push('C001', { ts: at(25), user: 'U004', text: 'Audio fixed — Dante patch was it. Thanks <@U002> :pray:', edited: { user: 'U004', ts: at(24) } });
fixed.reactions = [{ name: '+1', users: ['U002', 'U003', 'U001'], count: 3 }, { name: 'party_parrot', users: ['U005'], count: 1 }, { name: 'pray::skin-tone-3', users: ['U002'], count: 1 }];
push('C001', { ts: at(23), user: 'U002', text: 'Custom emoji check :party_parrot: :waters: and an unknown :not_an_emoji: stay put' });
push('C001', { ts: at(20), user: 'U002', text: '<!here> 10 minutes to doors. Campuses, post "ready" when your pre-service loop is running.' });
push('C001', { ts: at(15), user: 'U001', text: 'Apollo Beach ready' });
push('C001', { ts: at(14), user: 'U004', text: 'North Attleboro ready' });
push('C002', { ts: at(120), user: 'U002', text: 'Run of show for Sept 14 is pinned. Changes go here, not in DMs.' });
push('C002', { ts: at(90), user: 'U005', text: 'Bumper video is 0:42 this week, not 0:30 — adjust your timers.' });
push('G001', { ts: at(200), user: 'U002', text: 'Leads: budget call moved to Tuesday 2pm.' });
push('C003', { ts: at(300), user: 'U002', text: 'Staff meeting notes are in the drive.' });
}
seed();
const CHATTER = [
{ user: 'U003', text: 'Walk-in music is a touch loud at the back, can we come down 2 dB?' },
{ user: 'U002', text: 'Cue 4 is the *baptism video* — heads up, it has its own audio' },
{ user: 'U005', text: 'Livestream is live, 38 viewers already :tada:' },
{ user: 'U004', text: 'North Attleboro: pastor is running about 3 min long today' },
{ user: 'U002', text: 'Copy. Apollo Beach, hold your bumper until you hear from us' },
{ user: 'U003', text: 'Lyrics slide 3 of "Living Hope" has a typo — fixing for second service' },
{ user: 'U005', text: 'Stream bitrate dipped for a sec, back to normal now' },
{ user: 'U002', text: 'Offering moment is *after* the sermon this week, not before' },
{ user: 'U004', text: 'Anyone else seeing the ProPresenter stage display lag?' },
{ user: 'U003', text: 'Kids check-in is slammed, could use one more volunteer at the desk' },
{ user: 'U002', text: 'Great first service everyone :clap: reset in 15' },
];
let chatterIx = 0;
const OPS_CHATTER = [
{ user: 'U005', text: 'Ops: parking team is short two people, sending greeters over' },
{ user: 'U002', text: 'Cafe is out of oat milk again :sweat_smile:' },
{ user: 'U005', text: 'Second-service headcount: 412 in the room, 38 online' },
{ user: 'U002', text: 'Facilities: the lobby HVAC is back, thanks Dan' },
];
let opsIx = 0;
function tickNew() {
const line = CHATTER[chatterIx % CHATTER.length];
chatterIx += 1;
const m = push('C001', { user: line.user, text: line.text });
console.log(`[slack-mock] + new message ${m.ts} from ${line.user}`);
}
function tickOps() {
const line = OPS_CHATTER[opsIx % OPS_CHATTER.length];
opsIx += 1;
const m = push('C002', { user: line.user, text: line.text });
console.log(`[slack-mock] + new message ${m.ts} in #ops from ${line.user}`);
}
function tickEdit() {
const list = history.get('C001');
const recent = list.slice(-8).filter((m) => m.user && !m.subtype && !m.edited);
if (!recent.length) return;
const m = recent[Math.floor(Math.random() * recent.length)];
m.text += ' — fixed';
m.edited = { user: m.user, ts: ts() };
console.log(`[slack-mock] ~ edited ${m.ts}`);
}
function tickDelete() {
const list = history.get('C001');
const candidates = list.slice(-10, -2).filter((m) => m.user && !m.subtype && !m.reply_count);
if (!candidates.length) return;
const victim = candidates[Math.floor(Math.random() * candidates.length)];
list.splice(list.indexOf(victim), 1);
console.log(`[slack-mock] - deleted ${victim.ts}`);
}
function tickReply() {
const list = history.get('C001');
const parent = list.find((m) => m.reply_count);
if (!parent) return;
const reply = { type: 'message', ts: ts(), user: ['U002', 'U003', 'U005'][parent.replies.length % 3], text: `Thread reply ${parent.replies.length + 1} :eyes:`, thread_ts: parent.ts };
parent.replies.push(reply);
parent.reply_count = parent.replies.length;
parent.latest_reply = reply.ts;
console.log(`[slack-mock] ↳ reply on ${parent.ts} (${parent.reply_count} total)`);
}
setInterval(tickNew, 8000).unref();
setInterval(tickOps, 20000).unref();
setInterval(tickEdit, 25000).unref();
setInterval(tickDelete, 40000).unref();
setInterval(tickReply, 30000).unref();
/* ── plumbing ───────────────────────────────────────────────────────── */
function send(res, status, body, headers = {}) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers });
res.end(JSON.stringify(body));
}
function readBody(req) {
return new Promise((resolve) => {
let data = '';
req.on('data', (c) => { data += c; });
req.on('end', () => resolve(data));
req.on('error', () => resolve(''));
});
}
function parseParams(req, url, raw) {
const params = Object.fromEntries(url.searchParams.entries());
const ctype = String(req.headers['content-type'] || '');
if (raw) {
if (ctype.includes('application/json')) {
try { Object.assign(params, JSON.parse(raw)); } catch { /* ignore */ }
} else {
for (const [k, v] of new URLSearchParams(raw)) params[k] = v;
}
}
return params;
}
function tokenOf(req, params) {
const m = /^Bearer\s+(\S+)/.exec(String(req.headers.authorization || ''));
return m ? m[1] : String(params.token || '');
}
function identity(token) {
if (token === 'xoxp-revoked') return { error: 'token_revoked' };
if (/^xoxp-/.test(token)) return { ok: true, url: 'https://waters.slack.com/', team: 'Waters Church', user: 'apollo.beach', team_id: 'T001', user_id: 'U001', noscope: token === 'xoxp-noscope' };
if (/^xoxb-/.test(token)) return { ok: true, url: 'https://waters.slack.com/', team: 'Waters Church', user: 'proddash', team_id: 'T001', user_id: 'UBOT', bot_id: 'B001' };
return { error: 'invalid_auth' };
}
function channelRecord(c) {
return { id: c.id, name: c.name, name_normalized: c.name, is_channel: !c.is_private, is_group: c.is_private, is_private: c.is_private, is_member: c.is_member, is_archived: false, num_members: 12 };
}
function publicMessage(m) {
const { replies, ...rest } = m;
return rest;
}
function findChannel(ref) {
const s = String(ref || '');
return CHANNELS.find((c) => c.id === s || c.name === s.replace(/^#/, ''));
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
const raw = req.method === 'POST' ? await readBody(req) : '';
const params = parseParams(req, url, raw);
const method = url.pathname.replace(/^\/api\//, '');
const summary = Object.entries(params).filter(([k]) => k !== 'token').map(([k, v]) => `${k}=${String(v).slice(0, 40)}`).join(' ');
console.log(`[slack-mock] ${req.method} ${url.pathname} ${summary}`);
const img = url.pathname.match(/^\/img\/(u|e)\/([A-Za-z0-9_+-]+)\.svg$/);
if (img) {
const id = img[2];
const letter = img[1] === 'u' ? (USERS[id]?.display_name || USERS[id]?.real_name || id)[0].toUpperCase() : id[0].toUpperCase();
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-store' });
return res.end(squareSvg(id, letter));
}
if (!url.pathname.startsWith('/api/')) return send(res, 404, { ok: false, error: 'unknown_method' });
const token = tokenOf(req, params);
const who = identity(token);
if (!who.ok) return send(res, 200, { ok: false, error: who.error });
switch (method) {
case 'auth.test': {
const { noscope, ...rest } = who;
return send(res, 200, rest);
}
case 'users.info': {
const u = USERS[String(params.user)];
if (!u) return send(res, 200, { ok: false, error: 'user_not_found' });
const img = `http://127.0.0.1:${PORT}/img/u/${params.user}.svg`;
return send(res, 200, { ok: true, user: { id: params.user, name: u.name, real_name: u.real_name, is_bot: Boolean(u.is_bot), profile: { display_name: u.display_name, real_name: u.real_name, image_48: img, image_72: img } } });
}
case 'conversations.list': {
const types = String(params.types || 'public_channel').split(',');
let list = CHANNELS.filter((c) => (c.is_private ? types.includes('private_channel') : types.includes('public_channel')));
if (who.bot_id) list = list.filter((c) => c.is_member); // a bot sees only what it's in
const limit = Math.max(1, Math.min(1000, Number(params.limit) || 100));
const start = params.cursor ? Number(Buffer.from(String(params.cursor), 'base64').toString()) || 0 : 0;
const page = list.slice(start, start + limit);
const next = start + limit < list.length ? Buffer.from(String(start + limit)).toString('base64') : '';
return send(res, 200, { ok: true, channels: page.map(channelRecord), response_metadata: { next_cursor: next } });
}
case 'conversations.info': {
const c = findChannel(params.channel);
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
return send(res, 200, { ok: true, channel: channelRecord(c) });
}
case 'conversations.history': {
const c = findChannel(params.channel);
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
if (who.noscope) return send(res, 200, { ok: false, error: 'missing_scope', needed: 'channels:history', provided: 'channels:read,chat:write' });
if (!c.is_member) return send(res, 200, { ok: false, error: 'not_in_channel' });
const limit = Math.max(1, Math.min(1000, Number(params.limit) || 100));
const list = history.get(c.id);
const window = list.slice(-limit).reverse().map(publicMessage);
return send(res, 200, { ok: true, messages: window, has_more: list.length > limit, pin_count: 0 });
}
case 'conversations.replies': {
const c = findChannel(params.channel);
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
if (!c.is_member) return send(res, 200, { ok: false, error: 'not_in_channel' });
const parent = history.get(c.id).find((m) => m.ts === String(params.ts));
if (!parent) return send(res, 200, { ok: false, error: 'thread_not_found' });
const limit = Math.max(1, Math.min(1000, Number(params.limit) || 100));
return send(res, 200, { ok: true, messages: [publicMessage(parent), ...(parent.replies || []).slice(0, limit - 1)], has_more: false });
}
case 'emoji.list': {
const emoji = {};
for (const [name, v] of Object.entries(CUSTOM_EMOJI)) emoji[name] = v(PORT);
return send(res, 200, { ok: true, emoji, cache_ts: '1700000000.000000' });
}
case 'reactions.add':
case 'reactions.remove': {
const c = findChannel(params.channel);
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
if (!c.is_member) return send(res, 200, { ok: false, error: 'not_in_channel' });
const name = String(params.name || '');
if (!name || name === 'nope') return send(res, 200, { ok: false, error: 'invalid_name' });
const m = findMessage(c.id, String(params.timestamp || ''));
if (!m) return send(res, 200, { ok: false, error: 'message_not_found' });
const err = method === 'reactions.add' ? addReaction(m, name, who.user_id) : removeReaction(m, name, who.user_id);
if (err) return send(res, 200, { ok: false, error: err });
console.log(`[slack-mock] ${method === 'reactions.add' ? '➕' : '➖'} :${name}: on ${m.ts} in #${c.name} by ${who.user}`);
return send(res, 200, { ok: true });
}
case 'chat.postMessage': {
const c = findChannel(params.channel);
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
if (!c.is_member) return send(res, 200, { ok: false, error: 'not_in_channel' });
const text = String(params.text || '');
if (!text.trim()) return send(res, 200, { ok: false, error: 'no_text' });
if (text.includes('!429')) return send(res, 429, { ok: false, error: 'ratelimited' }, { 'Retry-After': '3' });
let m;
if (who.bot_id && params.username) {
m = push(c.id, { subtype: 'bot_message', bot_id: who.bot_id, username: String(params.username), icons: params.icon_emoji ? { emoji: String(params.icon_emoji) } : undefined, text });
} else if (who.bot_id) {
m = push(c.id, { user: who.user_id, bot_id: who.bot_id, bot_profile: { id: who.bot_id, name: 'ProdDash' }, text });
} else {
m = push(c.id, { user: who.user_id, text });
}
if (params.thread_ts) m.thread_ts = String(params.thread_ts);
console.log(`[slack-mock] ✉ posted to #${c.name} as ${params.username || who.user}: ${text.slice(0, 60)}`);
return send(res, 200, { ok: true, channel: c.id, ts: m.ts, message: publicMessage(m) });
}
default:
return send(res, 200, { ok: false, error: 'unknown_method' });
}
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`[slack-mock] Slack Web API mock on http://127.0.0.1:${PORT}/api`);
console.log('[slack-mock] tokens: xoxp-anything (user "Apollo Beach"), xoxb-anything (bot), xoxp-revoked, xoxp-noscope');
console.log('[slack-mock] run ProdDash with: SLACK_API_BASE=http://127.0.0.1:%d/api node server.js', PORT);
});